diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..f8c79f9c --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,86 @@ +module.exports = { + root: true, + env: { + node: true, + es2021: true, + commonjs: true + }, + extends: ['eslint:recommended', 'plugin:prettier/recommended'], + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest' + }, + plugins: ['prettier'], + rules: { + // 基础规则 + 'no-console': 'off', // Node.js 项目允许 console + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn', + 'prettier/prettier': 'error', + + // 变量相关 + 'no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrors: 'none' + } + ], + 'prefer-const': 'error', + 'no-var': 'error', + 'no-shadow': 'error', + + // 代码质量 + eqeqeq: ['error', 'always'], + curly: ['error', 'all'], + 'consistent-return': 'error', + 'no-throw-literal': 'error', + 'prefer-promise-reject-errors': 'error', + + // 代码风格 + 'object-shorthand': 'error', + 'prefer-template': 'error', + 'template-curly-spacing': ['error', 'never'], + + // Node.js 特定规则 + 'no-process-exit': 'error', + 'no-path-concat': 'error', + 'handle-callback-err': 'error', + + // ES6+ 规则 + 'arrow-body-style': ['error', 'as-needed'], + 'prefer-arrow-callback': 'error', + 'prefer-destructuring': [ + 'error', + { + array: false, + object: true + } + ], + + // 格式化规则(由 Prettier 处理) + semi: 'off', + quotes: 'off', + indent: 'off', + 'comma-dangle': 'off' + }, + overrides: [ + { + // CLI 和脚本文件 + files: ['cli/**/*.js', 'scripts/**/*.js'], + rules: { + 'no-process-exit': 'off' // CLI 脚本允许 process.exit + } + }, + { + // 测试文件 + files: ['**/*.test.js', '**/*.spec.js', 'tests/**/*.js'], + env: { + jest: true + }, + rules: { + 'no-unused-expressions': 'off' + } + } + ] +} diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index a8fbd64a..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,19 +0,0 @@ -module.exports = { - env: { - node: true, - es2022: true, - }, - extends: [ - 'eslint:recommended', - ], - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - }, - rules: { - 'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }], - 'no-console': 'off', - 'quotes': ['error', 'single'], - 'semi': ['error', 'always'], - }, -}; \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..7c104027 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +{ + "semi": false, + "trailingComma": "none", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "quoteProps": "as-needed", + "bracketSameLine": false, + "proseWrap": "preserve" +} diff --git a/cli/index.js b/cli/index.js index 1245189c..908a9311 100644 --- a/cli/index.js +++ b/cli/index.js @@ -1,22 +1,20 @@ #!/usr/bin/env node -const { Command } = require('commander'); -const inquirer = require('inquirer'); -const chalk = require('chalk'); -const ora = require('ora'); -const { table } = require('table'); -const bcrypt = require('bcryptjs'); -const crypto = require('crypto'); -const fs = require('fs'); -const path = require('path'); +const { Command } = require('commander') +const inquirer = require('inquirer') +const chalk = require('chalk') +const ora = require('ora') +const { table } = require('table') +const bcrypt = require('bcryptjs') +const fs = require('fs') +const path = require('path') -const config = require('../config/config'); -const redis = require('../src/models/redis'); -const apiKeyService = require('../src/services/apiKeyService'); -const claudeAccountService = require('../src/services/claudeAccountService'); -const bedrockAccountService = require('../src/services/bedrockAccountService'); +const redis = require('../src/models/redis') +const apiKeyService = require('../src/services/apiKeyService') +const claudeAccountService = require('../src/services/claudeAccountService') +const bedrockAccountService = require('../src/services/bedrockAccountService') -const program = new Command(); +const program = new Command() // 🎨 样式 const styles = { @@ -26,18 +24,18 @@ const styles = { warning: chalk.yellow, info: chalk.cyan, dim: chalk.dim -}; +} // 🔧 初始化 async function initialize() { - const spinner = ora('正在连接 Redis...').start(); + const spinner = ora('正在连接 Redis...').start() try { - await redis.connect(); - spinner.succeed('Redis 连接成功'); + await redis.connect() + spinner.succeed('Redis 连接成功') } catch (error) { - spinner.fail('Redis 连接失败'); - console.error(styles.error(error.message)); - process.exit(1); + spinner.fail('Redis 连接失败') + console.error(styles.error(error.message)) + process.exit(1) } } @@ -46,182 +44,186 @@ program .command('admin') .description('管理员账户操作') .action(async () => { - await initialize(); - - // 直接执行创建初始管理员 - await createInitialAdmin(); - - await redis.disconnect(); - }); + await initialize() + // 直接执行创建初始管理员 + await createInitialAdmin() + + await redis.disconnect() + }) // 🔑 API Key 管理 program .command('keys') .description('API Key 管理操作') .action(async () => { - await initialize(); - - const { action } = await inquirer.prompt([{ - type: 'list', - name: 'action', - message: '请选择操作:', - choices: [ - { name: '📋 查看所有 API Keys', value: 'list' }, - { name: '🔧 修改 API Key 过期时间', value: 'update-expiry' }, - { name: '🔄 续期即将过期的 API Key', value: 'renew' }, - { name: '🗑️ 删除 API Key', value: 'delete' } - ] - }]); - + await initialize() + + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: '请选择操作:', + choices: [ + { name: '📋 查看所有 API Keys', value: 'list' }, + { name: '🔧 修改 API Key 过期时间', value: 'update-expiry' }, + { name: '🔄 续期即将过期的 API Key', value: 'renew' }, + { name: '🗑️ 删除 API Key', value: 'delete' } + ] + } + ]) + switch (action) { case 'list': - await listApiKeys(); - break; + await listApiKeys() + break case 'update-expiry': - await updateApiKeyExpiry(); - break; + await updateApiKeyExpiry() + break case 'renew': - await renewApiKeys(); - break; + await renewApiKeys() + break case 'delete': - await deleteApiKey(); - break; + await deleteApiKey() + break } - - await redis.disconnect(); - }); + + await redis.disconnect() + }) // 📊 系统状态 program .command('status') .description('查看系统状态') .action(async () => { - await initialize(); - - const spinner = ora('正在获取系统状态...').start(); - + await initialize() + + const spinner = ora('正在获取系统状态...').start() + try { - const [systemStats, apiKeys, accounts] = await Promise.all([ + const [, apiKeys, accounts] = await Promise.all([ redis.getSystemStats(), apiKeyService.getAllApiKeys(), claudeAccountService.getAllAccounts() - ]); + ]) - spinner.succeed('系统状态获取成功'); + spinner.succeed('系统状态获取成功') + + console.log(styles.title('\n📊 系统状态概览\n')) - console.log(styles.title('\n📊 系统状态概览\n')); - const statusData = [ ['项目', '数量', '状态'], - ['API Keys', apiKeys.length, `${apiKeys.filter(k => k.isActive).length} 活跃`], - ['Claude 账户', accounts.length, `${accounts.filter(a => a.isActive).length} 活跃`], + ['API Keys', apiKeys.length, `${apiKeys.filter((k) => k.isActive).length} 活跃`], + ['Claude 账户', accounts.length, `${accounts.filter((a) => a.isActive).length} 活跃`], ['Redis 连接', redis.isConnected ? '已连接' : '未连接', redis.isConnected ? '🟢' : '🔴'], ['运行时间', `${Math.floor(process.uptime() / 60)} 分钟`, '🕐'] - ]; + ] - console.log(table(statusData)); + console.log(table(statusData)) // 使用统计 - const totalTokens = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0); - const totalRequests = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0); - - console.log(styles.title('\n📈 使用统计\n')); - console.log(`总 Token 使用量: ${styles.success(totalTokens.toLocaleString())}`); - console.log(`总请求数: ${styles.success(totalRequests.toLocaleString())}`); + const totalTokens = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0) + const totalRequests = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0) + console.log(styles.title('\n📈 使用统计\n')) + console.log(`总 Token 使用量: ${styles.success(totalTokens.toLocaleString())}`) + console.log(`总请求数: ${styles.success(totalRequests.toLocaleString())}`) } catch (error) { - spinner.fail('获取系统状态失败'); - console.error(styles.error(error.message)); + spinner.fail('获取系统状态失败') + console.error(styles.error(error.message)) } - - await redis.disconnect(); - }); + + await redis.disconnect() + }) // ☁️ Bedrock 账户管理 program .command('bedrock') .description('Bedrock 账户管理操作') .action(async () => { - await initialize(); - - const { action } = await inquirer.prompt([{ - type: 'list', - name: 'action', - message: '请选择操作:', - choices: [ - { name: '📋 查看所有 Bedrock 账户', value: 'list' }, - { name: '➕ 创建 Bedrock 账户', value: 'create' }, - { name: '✏️ 编辑 Bedrock 账户', value: 'edit' }, - { name: '🔄 切换账户状态', value: 'toggle' }, - { name: '🧪 测试账户连接', value: 'test' }, - { name: '🗑️ 删除账户', value: 'delete' } - ] - }]); - + await initialize() + + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: '请选择操作:', + choices: [ + { name: '📋 查看所有 Bedrock 账户', value: 'list' }, + { name: '➕ 创建 Bedrock 账户', value: 'create' }, + { name: '✏️ 编辑 Bedrock 账户', value: 'edit' }, + { name: '🔄 切换账户状态', value: 'toggle' }, + { name: '🧪 测试账户连接', value: 'test' }, + { name: '🗑️ 删除账户', value: 'delete' } + ] + } + ]) + switch (action) { case 'list': - await listBedrockAccounts(); - break; + await listBedrockAccounts() + break case 'create': - await createBedrockAccount(); - break; + await createBedrockAccount() + break case 'edit': - await editBedrockAccount(); - break; + await editBedrockAccount() + break case 'toggle': - await toggleBedrockAccount(); - break; + await toggleBedrockAccount() + break case 'test': - await testBedrockAccount(); - break; + await testBedrockAccount() + break case 'delete': - await deleteBedrockAccount(); - break; + await deleteBedrockAccount() + break } - - await redis.disconnect(); - }); + + await redis.disconnect() + }) // 实现具体功能函数 async function createInitialAdmin() { - console.log(styles.title('\n🔐 创建初始管理员账户\n')); - + console.log(styles.title('\n🔐 创建初始管理员账户\n')) + // 检查是否已存在 init.json - const initFilePath = path.join(__dirname, '..', 'data', 'init.json'); + const initFilePath = path.join(__dirname, '..', 'data', 'init.json') if (fs.existsSync(initFilePath)) { - const existingData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')); - console.log(styles.warning('⚠️ 检测到已存在管理员账户!')); - console.log(` 用户名: ${existingData.adminUsername}`); - console.log(` 创建时间: ${new Date(existingData.initializedAt).toLocaleString()}`); - - const { overwrite } = await inquirer.prompt([{ - type: 'confirm', - name: 'overwrite', - message: '是否覆盖现有管理员账户?', - default: false - }]); - + const existingData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')) + console.log(styles.warning('⚠️ 检测到已存在管理员账户!')) + console.log(` 用户名: ${existingData.adminUsername}`) + console.log(` 创建时间: ${new Date(existingData.initializedAt).toLocaleString()}`) + + const { overwrite } = await inquirer.prompt([ + { + type: 'confirm', + name: 'overwrite', + message: '是否覆盖现有管理员账户?', + default: false + } + ]) + if (!overwrite) { - console.log(styles.info('ℹ️ 已取消创建')); - return; + console.log(styles.info('ℹ️ 已取消创建')) + return } } - + const adminData = await inquirer.prompt([ { type: 'input', name: 'username', message: '用户名:', default: 'admin', - validate: input => input.length >= 3 || '用户名至少3个字符' + validate: (input) => input.length >= 3 || '用户名至少3个字符' }, { type: 'password', name: 'password', message: '密码:', - validate: input => input.length >= 8 || '密码至少8个字符' + validate: (input) => input.length >= 8 || '密码至少8个字符' }, { type: 'password', @@ -229,10 +231,10 @@ async function createInitialAdmin() { message: '确认密码:', validate: (input, answers) => input === answers.password || '密码不匹配' } - ]); + ]) + + const spinner = ora('正在创建管理员账户...').start() - const spinner = ora('正在创建管理员账户...').start(); - try { // 1. 先更新 init.json(唯一真实数据源) const initData = { @@ -241,148 +243,145 @@ async function createInitialAdmin() { adminPassword: adminData.password, // 保存明文密码 version: '1.0.0', updatedAt: new Date().toISOString() - }; - - // 确保 data 目录存在 - const dataDir = path.join(__dirname, '..', 'data'); - if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); } - + + // 确保 data 目录存在 + const dataDir = path.join(__dirname, '..', 'data') + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }) + } + // 写入文件 - fs.writeFileSync(initFilePath, JSON.stringify(initData, null, 2)); - + fs.writeFileSync(initFilePath, JSON.stringify(initData, null, 2)) + // 2. 再更新 Redis 缓存 - const passwordHash = await bcrypt.hash(adminData.password, 12); - + const passwordHash = await bcrypt.hash(adminData.password, 12) + const credentials = { username: adminData.username, passwordHash, createdAt: new Date().toISOString(), lastLogin: null, updatedAt: new Date().toISOString() - }; + } - await redis.setSession('admin_credentials', credentials, 0); // 永不过期 - - spinner.succeed('管理员账户创建成功'); - console.log(`${styles.success('✅')} 用户名: ${adminData.username}`); - console.log(`${styles.success('✅')} 密码: ${adminData.password}`); - console.log(`${styles.info('ℹ️')} 请妥善保管登录凭据`); - console.log(`${styles.info('ℹ️')} 凭据已保存到: ${initFilePath}`); - console.log(`${styles.warning('⚠️')} 如果服务正在运行,请重启服务以加载新凭据`); + await redis.setSession('admin_credentials', credentials, 0) // 永不过期 + spinner.succeed('管理员账户创建成功') + console.log(`${styles.success('✅')} 用户名: ${adminData.username}`) + console.log(`${styles.success('✅')} 密码: ${adminData.password}`) + console.log(`${styles.info('ℹ️')} 请妥善保管登录凭据`) + console.log(`${styles.info('ℹ️')} 凭据已保存到: ${initFilePath}`) + console.log(`${styles.warning('⚠️')} 如果服务正在运行,请重启服务以加载新凭据`) } catch (error) { - spinner.fail('创建管理员账户失败'); - console.error(styles.error(error.message)); + spinner.fail('创建管理员账户失败') + console.error(styles.error(error.message)) } } - - - - - // API Key 管理功能 async function listApiKeys() { - const spinner = ora('正在获取 API Keys...').start(); - + const spinner = ora('正在获取 API Keys...').start() + try { - const apiKeys = await apiKeyService.getAllApiKeys(); - spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`); + const apiKeys = await apiKeyService.getAllApiKeys() + spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`) if (apiKeys.length === 0) { - console.log(styles.warning('没有找到任何 API Keys')); - return; + console.log(styles.warning('没有找到任何 API Keys')) + return } - const tableData = [ - ['名称', 'API Key', '状态', '过期时间', '使用量', 'Token限制'] - ]; + const tableData = [['名称', 'API Key', '状态', '过期时间', '使用量', 'Token限制']] + + apiKeys.forEach((key) => { + const now = new Date() + const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null + let expiryStatus = '永不过期' - apiKeys.forEach(key => { - const now = new Date(); - const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null; - let expiryStatus = '永不过期'; - if (expiresAt) { if (expiresAt < now) { - expiryStatus = styles.error(`已过期 (${expiresAt.toLocaleDateString()})`); + expiryStatus = styles.error(`已过期 (${expiresAt.toLocaleDateString()})`) } else { - const daysLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24)); + const daysLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24)) if (daysLeft <= 7) { - expiryStatus = styles.warning(`${daysLeft}天后过期 (${expiresAt.toLocaleDateString()})`); + expiryStatus = styles.warning(`${daysLeft}天后过期 (${expiresAt.toLocaleDateString()})`) } else { - expiryStatus = styles.success(`${expiresAt.toLocaleDateString()}`); + expiryStatus = styles.success(`${expiresAt.toLocaleDateString()}`) } } } tableData.push([ key.name, - key.apiKey ? key.apiKey.substring(0, 20) + '...' : '-', + key.apiKey ? `${key.apiKey.substring(0, 20)}...` : '-', key.isActive ? '🟢 活跃' : '🔴 停用', expiryStatus, `${(key.usage?.total?.tokens || 0).toLocaleString()}`, key.tokenLimit ? key.tokenLimit.toLocaleString() : '无限制' - ]); - }); - - console.log(styles.title('\n🔑 API Keys 列表:\n')); - console.log(table(tableData)); + ]) + }) + console.log(styles.title('\n🔑 API Keys 列表:\n')) + console.log(table(tableData)) } catch (error) { - spinner.fail('获取 API Keys 失败'); - console.error(styles.error(error.message)); + spinner.fail('获取 API Keys 失败') + console.error(styles.error(error.message)) } } async function updateApiKeyExpiry() { try { // 获取所有 API Keys - const apiKeys = await apiKeyService.getAllApiKeys(); - + const apiKeys = await apiKeyService.getAllApiKeys() + if (apiKeys.length === 0) { - console.log(styles.warning('没有找到任何 API Keys')); - return; + console.log(styles.warning('没有找到任何 API Keys')) + return } // 选择要修改的 API Key - const { selectedKey } = await inquirer.prompt([{ - type: 'list', - name: 'selectedKey', - message: '选择要修改的 API Key:', - choices: apiKeys.map(key => ({ - name: `${key.name} (${key.apiKey?.substring(0, 20)}...) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`, - value: key - })) - }]); + const { selectedKey } = await inquirer.prompt([ + { + type: 'list', + name: 'selectedKey', + message: '选择要修改的 API Key:', + choices: apiKeys.map((key) => ({ + name: `${key.name} (${key.apiKey?.substring(0, 20)}...) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`, + value: key + })) + } + ]) - console.log(`\n当前 API Key: ${selectedKey.name}`); - console.log(`当前过期时间: ${selectedKey.expiresAt ? new Date(selectedKey.expiresAt).toLocaleString() : '永不过期'}`); + console.log(`\n当前 API Key: ${selectedKey.name}`) + console.log( + `当前过期时间: ${selectedKey.expiresAt ? new Date(selectedKey.expiresAt).toLocaleString() : '永不过期'}` + ) // 选择新的过期时间 - const { expiryOption } = await inquirer.prompt([{ - type: 'list', - name: 'expiryOption', - message: '选择新的过期时间:', - choices: [ - { name: '⏰ 1分后(测试用)', value: '1m' }, - { name: '⏰ 1小时后(测试用)', value: '1h' }, - { name: '📅 1天后', value: '1d' }, - { name: '📅 7天后', value: '7d' }, - { name: '📅 30天后', value: '30d' }, - { name: '📅 90天后', value: '90d' }, - { name: '📅 365天后', value: '365d' }, - { name: '♾️ 永不过期', value: 'never' }, - { name: '🎯 自定义日期时间', value: 'custom' } - ] - }]); + const { expiryOption } = await inquirer.prompt([ + { + type: 'list', + name: 'expiryOption', + message: '选择新的过期时间:', + choices: [ + { name: '⏰ 1分后(测试用)', value: '1m' }, + { name: '⏰ 1小时后(测试用)', value: '1h' }, + { name: '📅 1天后', value: '1d' }, + { name: '📅 7天后', value: '7d' }, + { name: '📅 30天后', value: '30d' }, + { name: '📅 90天后', value: '90d' }, + { name: '📅 365天后', value: '365d' }, + { name: '♾️ 永不过期', value: 'never' }, + { name: '🎯 自定义日期时间', value: 'custom' } + ] + } + ]) - let newExpiresAt = null; + let newExpiresAt = null if (expiryOption === 'never') { - newExpiresAt = null; + newExpiresAt = null } else if (expiryOption === 'custom') { const { customDate, customTime } = await inquirer.prompt([ { @@ -390,9 +389,9 @@ async function updateApiKeyExpiry() { name: 'customDate', message: '输入日期 (YYYY-MM-DD):', default: new Date().toISOString().split('T')[0], - validate: input => { - const date = new Date(input); - return !isNaN(date.getTime()) || '请输入有效的日期格式'; + validate: (input) => { + const date = new Date(input) + return !isNaN(date.getTime()) || '请输入有效的日期格式' } }, { @@ -400,16 +399,14 @@ async function updateApiKeyExpiry() { name: 'customTime', message: '输入时间 (HH:MM):', default: '00:00', - validate: input => { - return /^\d{2}:\d{2}$/.test(input) || '请输入有效的时间格式 (HH:MM)'; - } + validate: (input) => /^\d{2}:\d{2}$/.test(input) || '请输入有效的时间格式 (HH:MM)' } - ]); - - newExpiresAt = new Date(`${customDate}T${customTime}:00`).toISOString(); + ]) + + newExpiresAt = new Date(`${customDate}T${customTime}:00`).toISOString() } else { // 计算新的过期时间 - const now = new Date(); + const now = new Date() const durations = { '1m': 60 * 1000, '1h': 60 * 60 * 1000, @@ -418,283 +415,295 @@ async function updateApiKeyExpiry() { '30d': 30 * 24 * 60 * 60 * 1000, '90d': 90 * 24 * 60 * 60 * 1000, '365d': 365 * 24 * 60 * 60 * 1000 - }; - - newExpiresAt = new Date(now.getTime() + durations[expiryOption]).toISOString(); + } + + newExpiresAt = new Date(now.getTime() + durations[expiryOption]).toISOString() } // 确认修改 - const confirmMsg = newExpiresAt + const confirmMsg = newExpiresAt ? `确认将过期时间修改为: ${new Date(newExpiresAt).toLocaleString()}?` - : '确认设置为永不过期?'; - - const { confirmed } = await inquirer.prompt([{ - type: 'confirm', - name: 'confirmed', - message: confirmMsg, - default: true - }]); + : '确认设置为永不过期?' + + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + message: confirmMsg, + default: true + } + ]) if (!confirmed) { - console.log(styles.info('已取消修改')); - return; + console.log(styles.info('已取消修改')) + return } // 执行修改 - const spinner = ora('正在修改过期时间...').start(); - - try { - await apiKeyService.updateApiKey(selectedKey.id, { expiresAt: newExpiresAt }); - spinner.succeed('过期时间修改成功'); - - console.log(styles.success(`\n✅ API Key "${selectedKey.name}" 的过期时间已更新`)); - console.log(`新的过期时间: ${newExpiresAt ? new Date(newExpiresAt).toLocaleString() : '永不过期'}`); - - } catch (error) { - spinner.fail('修改失败'); - console.error(styles.error(error.message)); - } + const spinner = ora('正在修改过期时间...').start() + try { + await apiKeyService.updateApiKey(selectedKey.id, { expiresAt: newExpiresAt }) + spinner.succeed('过期时间修改成功') + + console.log(styles.success(`\n✅ API Key "${selectedKey.name}" 的过期时间已更新`)) + console.log( + `新的过期时间: ${newExpiresAt ? new Date(newExpiresAt).toLocaleString() : '永不过期'}` + ) + } catch (error) { + spinner.fail('修改失败') + console.error(styles.error(error.message)) + } } catch (error) { - console.error(styles.error('操作失败:', error.message)); + console.error(styles.error('操作失败:', error.message)) } } async function renewApiKeys() { - const spinner = ora('正在查找即将过期的 API Keys...').start(); - + const spinner = ora('正在查找即将过期的 API Keys...').start() + try { - const apiKeys = await apiKeyService.getAllApiKeys(); - const now = new Date(); - const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); - + const apiKeys = await apiKeyService.getAllApiKeys() + const now = new Date() + const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) + // 筛选即将过期的 Keys(7天内) - const expiringKeys = apiKeys.filter(key => { - if (!key.expiresAt) return false; - const expiresAt = new Date(key.expiresAt); - return expiresAt > now && expiresAt <= sevenDaysLater; - }); - - spinner.stop(); - + const expiringKeys = apiKeys.filter((key) => { + if (!key.expiresAt) { + return false + } + const expiresAt = new Date(key.expiresAt) + return expiresAt > now && expiresAt <= sevenDaysLater + }) + + spinner.stop() + if (expiringKeys.length === 0) { - console.log(styles.info('没有即将过期的 API Keys(7天内)')); - return; + console.log(styles.info('没有即将过期的 API Keys(7天内)')) + return } - - console.log(styles.warning(`\n找到 ${expiringKeys.length} 个即将过期的 API Keys:\n`)); - + + console.log(styles.warning(`\n找到 ${expiringKeys.length} 个即将过期的 API Keys:\n`)) + expiringKeys.forEach((key, index) => { - const daysLeft = Math.ceil((new Date(key.expiresAt) - now) / (1000 * 60 * 60 * 24)); - console.log(`${index + 1}. ${key.name} - ${daysLeft}天后过期 (${new Date(key.expiresAt).toLocaleDateString()})`); - }); - - const { renewOption } = await inquirer.prompt([{ - type: 'list', - name: 'renewOption', - message: '选择续期方式:', - choices: [ - { name: '📅 全部续期30天', value: 'all30' }, - { name: '📅 全部续期90天', value: 'all90' }, - { name: '🎯 逐个选择续期', value: 'individual' } - ] - }]); - + const daysLeft = Math.ceil((new Date(key.expiresAt) - now) / (1000 * 60 * 60 * 24)) + console.log( + `${index + 1}. ${key.name} - ${daysLeft}天后过期 (${new Date(key.expiresAt).toLocaleDateString()})` + ) + }) + + const { renewOption } = await inquirer.prompt([ + { + type: 'list', + name: 'renewOption', + message: '选择续期方式:', + choices: [ + { name: '📅 全部续期30天', value: 'all30' }, + { name: '📅 全部续期90天', value: 'all90' }, + { name: '🎯 逐个选择续期', value: 'individual' } + ] + } + ]) + if (renewOption.startsWith('all')) { - const days = renewOption === 'all30' ? 30 : 90; - const renewSpinner = ora(`正在为所有 API Keys 续期 ${days} 天...`).start(); - + const days = renewOption === 'all30' ? 30 : 90 + const renewSpinner = ora(`正在为所有 API Keys 续期 ${days} 天...`).start() + for (const key of expiringKeys) { try { - const newExpiresAt = new Date(new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000).toISOString(); - await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt }); + const newExpiresAt = new Date( + new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000 + ).toISOString() + await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt }) } catch (error) { - renewSpinner.fail(`续期 ${key.name} 失败: ${error.message}`); + renewSpinner.fail(`续期 ${key.name} 失败: ${error.message}`) } } - - renewSpinner.succeed(`成功续期 ${expiringKeys.length} 个 API Keys`); - + + renewSpinner.succeed(`成功续期 ${expiringKeys.length} 个 API Keys`) } else { // 逐个选择续期 for (const key of expiringKeys) { - console.log(`\n处理: ${key.name}`); - - const { action } = await inquirer.prompt([{ - type: 'list', - name: 'action', - message: '选择操作:', - choices: [ - { name: '续期30天', value: '30' }, - { name: '续期90天', value: '90' }, - { name: '跳过', value: 'skip' } - ] - }]); - + console.log(`\n处理: ${key.name}`) + + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: '选择操作:', + choices: [ + { name: '续期30天', value: '30' }, + { name: '续期90天', value: '90' }, + { name: '跳过', value: 'skip' } + ] + } + ]) + if (action !== 'skip') { - const days = parseInt(action); - const newExpiresAt = new Date(new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000).toISOString(); - + const days = parseInt(action) + const newExpiresAt = new Date( + new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000 + ).toISOString() + try { - await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt }); - console.log(styles.success(`✅ 已续期 ${days} 天`)); + await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt }) + console.log(styles.success(`✅ 已续期 ${days} 天`)) } catch (error) { - console.log(styles.error(`❌ 续期失败: ${error.message}`)); + console.log(styles.error(`❌ 续期失败: ${error.message}`)) } } } } - } catch (error) { - spinner.fail('操作失败'); - console.error(styles.error(error.message)); + spinner.fail('操作失败') + console.error(styles.error(error.message)) } } async function deleteApiKey() { try { - const apiKeys = await apiKeyService.getAllApiKeys(); - + const apiKeys = await apiKeyService.getAllApiKeys() + if (apiKeys.length === 0) { - console.log(styles.warning('没有找到任何 API Keys')); - return; + console.log(styles.warning('没有找到任何 API Keys')) + return } - const { selectedKeys } = await inquirer.prompt([{ - type: 'checkbox', - name: 'selectedKeys', - message: '选择要删除的 API Keys (空格选择,回车确认):', - choices: apiKeys.map(key => ({ - name: `${key.name} (${key.apiKey?.substring(0, 20)}...)`, - value: key.id - })) - }]); + const { selectedKeys } = await inquirer.prompt([ + { + type: 'checkbox', + name: 'selectedKeys', + message: '选择要删除的 API Keys (空格选择,回车确认):', + choices: apiKeys.map((key) => ({ + name: `${key.name} (${key.apiKey?.substring(0, 20)}...)`, + value: key.id + })) + } + ]) if (selectedKeys.length === 0) { - console.log(styles.info('未选择任何 API Key')); - return; + console.log(styles.info('未选择任何 API Key')) + return } - const { confirmed } = await inquirer.prompt([{ - type: 'confirm', - name: 'confirmed', - message: styles.warning(`确认删除 ${selectedKeys.length} 个 API Keys?`), - default: false - }]); + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + message: styles.warning(`确认删除 ${selectedKeys.length} 个 API Keys?`), + default: false + } + ]) if (!confirmed) { - console.log(styles.info('已取消删除')); - return; + console.log(styles.info('已取消删除')) + return } - const spinner = ora('正在删除 API Keys...').start(); - let successCount = 0; + const spinner = ora('正在删除 API Keys...').start() + let successCount = 0 for (const keyId of selectedKeys) { try { - await apiKeyService.deleteApiKey(keyId); - successCount++; + await apiKeyService.deleteApiKey(keyId) + successCount++ } catch (error) { - spinner.fail(`删除失败: ${error.message}`); + spinner.fail(`删除失败: ${error.message}`) } } - spinner.succeed(`成功删除 ${successCount}/${selectedKeys.length} 个 API Keys`); - + spinner.succeed(`成功删除 ${successCount}/${selectedKeys.length} 个 API Keys`) } catch (error) { - console.error(styles.error('删除失败:', error.message)); + console.error(styles.error('删除失败:', error.message)) } } -async function listClaudeAccounts() { - const spinner = ora('正在获取 Claude 账户...').start(); - - try { - const accounts = await claudeAccountService.getAllAccounts(); - spinner.succeed(`找到 ${accounts.length} 个 Claude 账户`); +// async function listClaudeAccounts() { +// const spinner = ora('正在获取 Claude 账户...').start(); - if (accounts.length === 0) { - console.log(styles.warning('没有找到任何 Claude 账户')); - return; - } +// try { +// const accounts = await claudeAccountService.getAllAccounts(); +// spinner.succeed(`找到 ${accounts.length} 个 Claude 账户`); - const tableData = [ - ['ID', '名称', '邮箱', '状态', '代理', '最后使用'] - ]; +// if (accounts.length === 0) { +// console.log(styles.warning('没有找到任何 Claude 账户')); +// return; +// } - accounts.forEach(account => { - tableData.push([ - account.id.substring(0, 8) + '...', - account.name, - account.email || '-', - account.isActive ? (account.status === 'active' ? '🟢 活跃' : '🟡 待激活') : '🔴 停用', - account.proxy ? '🌐 是' : '-', - account.lastUsedAt ? new Date(account.lastUsedAt).toLocaleDateString() : '-' - ]); - }); +// const tableData = [ +// ['ID', '名称', '邮箱', '状态', '代理', '最后使用'] +// ]; - console.log('\n🏢 Claude 账户列表:\n'); - console.log(table(tableData)); +// accounts.forEach(account => { +// tableData.push([ +// account.id.substring(0, 8) + '...', +// account.name, +// account.email || '-', +// account.isActive ? (account.status === 'active' ? '🟢 活跃' : '🟡 待激活') : '🔴 停用', +// account.proxy ? '🌐 是' : '-', +// account.lastUsedAt ? new Date(account.lastUsedAt).toLocaleDateString() : '-' +// ]); +// }); - } catch (error) { - spinner.fail('获取 Claude 账户失败'); - console.error(styles.error(error.message)); - } -} +// console.log('\n🏢 Claude 账户列表:\n'); +// console.log(table(tableData)); + +// } catch (error) { +// spinner.fail('获取 Claude 账户失败'); +// console.error(styles.error(error.message)); +// } +// } // ☁️ Bedrock 账户管理函数 async function listBedrockAccounts() { - const spinner = ora('正在获取 Bedrock 账户...').start(); - + const spinner = ora('正在获取 Bedrock 账户...').start() + try { - const result = await bedrockAccountService.getAllAccounts(); + const result = await bedrockAccountService.getAllAccounts() if (!result.success) { - throw new Error(result.error); + throw new Error(result.error) } - - const accounts = result.data; - spinner.succeed(`找到 ${accounts.length} 个 Bedrock 账户`); + + const accounts = result.data + spinner.succeed(`找到 ${accounts.length} 个 Bedrock 账户`) if (accounts.length === 0) { - console.log(styles.warning('没有找到任何 Bedrock 账户')); - return; + console.log(styles.warning('没有找到任何 Bedrock 账户')) + return } - const tableData = [ - ['ID', '名称', '区域', '模型', '状态', '凭证类型', '创建时间'] - ]; + const tableData = [['ID', '名称', '区域', '模型', '状态', '凭证类型', '创建时间']] - accounts.forEach(account => { + accounts.forEach((account) => { tableData.push([ - account.id.substring(0, 8) + '...', + `${account.id.substring(0, 8)}...`, account.name, account.region, account.defaultModel?.split('.').pop() || 'default', account.isActive ? (account.schedulable ? '🟢 活跃' : '🟡 不可调度') : '🔴 停用', account.credentialType, account.createdAt ? new Date(account.createdAt).toLocaleDateString() : '-' - ]); - }); - - console.log('\n☁️ Bedrock 账户列表:\n'); - console.log(table(tableData)); + ]) + }) + console.log('\n☁️ Bedrock 账户列表:\n') + console.log(table(tableData)) } catch (error) { - spinner.fail('获取 Bedrock 账户失败'); - console.error(styles.error(error.message)); + spinner.fail('获取 Bedrock 账户失败') + console.error(styles.error(error.message)) } } async function createBedrockAccount() { - console.log(styles.title('\n➕ 创建 Bedrock 账户\n')); - + console.log(styles.title('\n➕ 创建 Bedrock 账户\n')) + const questions = [ { type: 'input', name: 'name', message: '账户名称:', - validate: input => input.trim() !== '' + validate: (input) => input.trim() !== '' }, { type: 'input', @@ -722,185 +731,190 @@ async function createBedrockAccount() { { name: 'Bearer Token (API Key)', value: 'bearer_token' } ] } - ]; - + ] + // 根据凭证类型添加额外问题 - const answers = await inquirer.prompt(questions); - + const answers = await inquirer.prompt(questions) + if (answers.credentialType === 'access_key') { const credQuestions = await inquirer.prompt([ { type: 'input', name: 'accessKeyId', message: 'AWS Access Key ID:', - validate: input => input.trim() !== '' + validate: (input) => input.trim() !== '' }, { type: 'password', name: 'secretAccessKey', message: 'AWS Secret Access Key:', - validate: input => input.trim() !== '' + validate: (input) => input.trim() !== '' }, { type: 'input', name: 'sessionToken', message: 'Session Token (可选,用于临时凭证):' } - ]); - + ]) + answers.awsCredentials = { accessKeyId: credQuestions.accessKeyId, secretAccessKey: credQuestions.secretAccessKey - }; - + } + if (credQuestions.sessionToken) { - answers.awsCredentials.sessionToken = credQuestions.sessionToken; + answers.awsCredentials.sessionToken = credQuestions.sessionToken } } - - const spinner = ora('正在创建 Bedrock 账户...').start(); - + + const spinner = ora('正在创建 Bedrock 账户...').start() + try { - const result = await bedrockAccountService.createAccount(answers); - + const result = await bedrockAccountService.createAccount(answers) + if (!result.success) { - throw new Error(result.error); + throw new Error(result.error) } - - spinner.succeed('Bedrock 账户创建成功'); - console.log(styles.success(`账户 ID: ${result.data.id}`)); - console.log(styles.info(`名称: ${result.data.name}`)); - console.log(styles.info(`区域: ${result.data.region}`)); - + + spinner.succeed('Bedrock 账户创建成功') + console.log(styles.success(`账户 ID: ${result.data.id}`)) + console.log(styles.info(`名称: ${result.data.name}`)) + console.log(styles.info(`区域: ${result.data.region}`)) } catch (error) { - spinner.fail('创建 Bedrock 账户失败'); - console.error(styles.error(error.message)); + spinner.fail('创建 Bedrock 账户失败') + console.error(styles.error(error.message)) } } async function testBedrockAccount() { - const spinner = ora('正在获取 Bedrock 账户...').start(); - + const spinner = ora('正在获取 Bedrock 账户...').start() + try { - const result = await bedrockAccountService.getAllAccounts(); + const result = await bedrockAccountService.getAllAccounts() if (!result.success || result.data.length === 0) { - spinner.fail('没有可测试的 Bedrock 账户'); - return; + spinner.fail('没有可测试的 Bedrock 账户') + return } - - spinner.succeed('账户列表获取成功'); - - const choices = result.data.map(account => ({ + + spinner.succeed('账户列表获取成功') + + const choices = result.data.map((account) => ({ name: `${account.name} (${account.region})`, value: account.id - })); - - const { accountId } = await inquirer.prompt([{ - type: 'list', - name: 'accountId', - message: '选择要测试的账户:', - choices - }]); - - const testSpinner = ora('正在测试账户连接...').start(); - - const testResult = await bedrockAccountService.testAccount(accountId); - + })) + + const { accountId } = await inquirer.prompt([ + { + type: 'list', + name: 'accountId', + message: '选择要测试的账户:', + choices + } + ]) + + const testSpinner = ora('正在测试账户连接...').start() + + const testResult = await bedrockAccountService.testAccount(accountId) + if (testResult.success) { - testSpinner.succeed('账户连接测试成功'); - console.log(styles.success(`状态: ${testResult.data.status}`)); - console.log(styles.info(`区域: ${testResult.data.region}`)); - console.log(styles.info(`可用模型数量: ${testResult.data.modelsCount || 'N/A'}`)); + testSpinner.succeed('账户连接测试成功') + console.log(styles.success(`状态: ${testResult.data.status}`)) + console.log(styles.info(`区域: ${testResult.data.region}`)) + console.log(styles.info(`可用模型数量: ${testResult.data.modelsCount || 'N/A'}`)) } else { - testSpinner.fail('账户连接测试失败'); - console.error(styles.error(testResult.error)); + testSpinner.fail('账户连接测试失败') + console.error(styles.error(testResult.error)) } - } catch (error) { - spinner.fail('测试过程中发生错误'); - console.error(styles.error(error.message)); + spinner.fail('测试过程中发生错误') + console.error(styles.error(error.message)) } } async function toggleBedrockAccount() { - const spinner = ora('正在获取 Bedrock 账户...').start(); - + const spinner = ora('正在获取 Bedrock 账户...').start() + try { - const result = await bedrockAccountService.getAllAccounts(); + const result = await bedrockAccountService.getAllAccounts() if (!result.success || result.data.length === 0) { - spinner.fail('没有可操作的 Bedrock 账户'); - return; + spinner.fail('没有可操作的 Bedrock 账户') + return } - - spinner.succeed('账户列表获取成功'); - - const choices = result.data.map(account => ({ + + spinner.succeed('账户列表获取成功') + + const choices = result.data.map((account) => ({ name: `${account.name} (${account.isActive ? '🟢 活跃' : '🔴 停用'})`, value: account.id - })); - - const { accountId } = await inquirer.prompt([{ - type: 'list', - name: 'accountId', - message: '选择要切换状态的账户:', - choices - }]); - - const toggleSpinner = ora('正在切换账户状态...').start(); - + })) + + const { accountId } = await inquirer.prompt([ + { + type: 'list', + name: 'accountId', + message: '选择要切换状态的账户:', + choices + } + ]) + + const toggleSpinner = ora('正在切换账户状态...').start() + // 获取当前状态 - const accountResult = await bedrockAccountService.getAccount(accountId); + const accountResult = await bedrockAccountService.getAccount(accountId) if (!accountResult.success) { - throw new Error('无法获取账户信息'); + throw new Error('无法获取账户信息') } - - const newStatus = !accountResult.data.isActive; - const updateResult = await bedrockAccountService.updateAccount(accountId, { isActive: newStatus }); - + + const newStatus = !accountResult.data.isActive + const updateResult = await bedrockAccountService.updateAccount(accountId, { + isActive: newStatus + }) + if (updateResult.success) { - toggleSpinner.succeed('账户状态切换成功'); - console.log(styles.success(`新状态: ${newStatus ? '🟢 活跃' : '🔴 停用'}`)); + toggleSpinner.succeed('账户状态切换成功') + console.log(styles.success(`新状态: ${newStatus ? '🟢 活跃' : '🔴 停用'}`)) } else { - throw new Error(updateResult.error); + throw new Error(updateResult.error) } - } catch (error) { - spinner.fail('切换账户状态失败'); - console.error(styles.error(error.message)); + spinner.fail('切换账户状态失败') + console.error(styles.error(error.message)) } } async function editBedrockAccount() { - const spinner = ora('正在获取 Bedrock 账户...').start(); - + const spinner = ora('正在获取 Bedrock 账户...').start() + try { - const result = await bedrockAccountService.getAllAccounts(); + const result = await bedrockAccountService.getAllAccounts() if (!result.success || result.data.length === 0) { - spinner.fail('没有可编辑的 Bedrock 账户'); - return; + spinner.fail('没有可编辑的 Bedrock 账户') + return } - - spinner.succeed('账户列表获取成功'); - - const choices = result.data.map(account => ({ + + spinner.succeed('账户列表获取成功') + + const choices = result.data.map((account) => ({ name: `${account.name} (${account.region})`, value: account.id - })); - - const { accountId } = await inquirer.prompt([{ - type: 'list', - name: 'accountId', - message: '选择要编辑的账户:', - choices - }]); - - const accountResult = await bedrockAccountService.getAccount(accountId); + })) + + const { accountId } = await inquirer.prompt([ + { + type: 'list', + name: 'accountId', + message: '选择要编辑的账户:', + choices + } + ]) + + const accountResult = await bedrockAccountService.getAccount(accountId) if (!accountResult.success) { - throw new Error('无法获取账户信息'); + throw new Error('无法获取账户信息') } - - const account = accountResult.data; - + + const account = accountResult.data + const updates = await inquirer.prompt([ { type: 'input', @@ -919,94 +933,93 @@ async function editBedrockAccount() { name: 'priority', message: '优先级 (1-100):', default: account.priority, - validate: input => input >= 1 && input <= 100 + validate: (input) => input >= 1 && input <= 100 } - ]); - - const updateSpinner = ora('正在更新账户...').start(); - - const updateResult = await bedrockAccountService.updateAccount(accountId, updates); - + ]) + + const updateSpinner = ora('正在更新账户...').start() + + const updateResult = await bedrockAccountService.updateAccount(accountId, updates) + if (updateResult.success) { - updateSpinner.succeed('账户更新成功'); + updateSpinner.succeed('账户更新成功') } else { - throw new Error(updateResult.error); + throw new Error(updateResult.error) } - } catch (error) { - spinner.fail('编辑账户失败'); - console.error(styles.error(error.message)); + spinner.fail('编辑账户失败') + console.error(styles.error(error.message)) } } async function deleteBedrockAccount() { - const spinner = ora('正在获取 Bedrock 账户...').start(); - + const spinner = ora('正在获取 Bedrock 账户...').start() + try { - const result = await bedrockAccountService.getAllAccounts(); + const result = await bedrockAccountService.getAllAccounts() if (!result.success || result.data.length === 0) { - spinner.fail('没有可删除的 Bedrock 账户'); - return; + spinner.fail('没有可删除的 Bedrock 账户') + return } - - spinner.succeed('账户列表获取成功'); - - const choices = result.data.map(account => ({ + + spinner.succeed('账户列表获取成功') + + const choices = result.data.map((account) => ({ name: `${account.name} (${account.region})`, value: { id: account.id, name: account.name } - })); - - const { account } = await inquirer.prompt([{ - type: 'list', - name: 'account', - message: '选择要删除的账户:', - choices - }]); - - const { confirm } = await inquirer.prompt([{ - type: 'confirm', - name: 'confirm', - message: `确定要删除账户 "${account.name}" 吗?此操作无法撤销!`, - default: false - }]); - + })) + + const { account } = await inquirer.prompt([ + { + type: 'list', + name: 'account', + message: '选择要删除的账户:', + choices + } + ]) + + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: `确定要删除账户 "${account.name}" 吗?此操作无法撤销!`, + default: false + } + ]) + if (!confirm) { - console.log(styles.info('已取消删除')); - return; + console.log(styles.info('已取消删除')) + return } - - const deleteSpinner = ora('正在删除账户...').start(); - - const deleteResult = await bedrockAccountService.deleteAccount(account.id); - + + const deleteSpinner = ora('正在删除账户...').start() + + const deleteResult = await bedrockAccountService.deleteAccount(account.id) + if (deleteResult.success) { - deleteSpinner.succeed('账户删除成功'); + deleteSpinner.succeed('账户删除成功') } else { - throw new Error(deleteResult.error); + throw new Error(deleteResult.error) } - } catch (error) { - spinner.fail('删除账户失败'); - console.error(styles.error(error.message)); + spinner.fail('删除账户失败') + console.error(styles.error(error.message)) } } // 程序信息 -program - .name('claude-relay-cli') - .description('Claude Relay Service 命令行管理工具') - .version('1.0.0'); +program.name('claude-relay-cli').description('Claude Relay Service 命令行管理工具').version('1.0.0') // 解析命令行参数 -program.parse(); +program.parse() // 如果没有提供命令,显示帮助 if (!process.argv.slice(2).length) { - console.log(styles.title('🚀 Claude Relay Service CLI\n')); - console.log('使用以下命令管理服务:\n'); - console.log(' claude-relay-cli admin - 创建初始管理员账户'); - console.log(' claude-relay-cli keys - API Key 管理(查看/修改过期时间/续期/删除)'); - console.log(' claude-relay-cli bedrock - Bedrock 账户管理(创建/查看/编辑/测试/删除)'); - console.log(' claude-relay-cli status - 查看系统状态'); - console.log('\n使用 --help 查看详细帮助信息'); -} \ No newline at end of file + console.log(styles.title('🚀 Claude Relay Service CLI\n')) + console.log('使用以下命令管理服务:\n') + console.log(' claude-relay-cli admin - 创建初始管理员账户') + console.log(' claude-relay-cli keys - API Key 管理(查看/修改过期时间/续期/删除)') + console.log(' claude-relay-cli bedrock - Bedrock 账户管理(创建/查看/编辑/测试/删除)') + console.log(' claude-relay-cli status - 查看系统状态') + console.log('\n使用 --help 查看详细帮助信息') +} diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 00000000..c4b3c5fb --- /dev/null +++ b/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["src"], + "ext": "js,json", + "exec": "npm run lint && node src/app.js" +} diff --git a/package-lock.json b/package-lock.json index 70676d3e..98b89998 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,8 +36,11 @@ "devDependencies": { "@types/node": "^20.8.9", "eslint": "^8.53.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", "jest": "^29.7.0", "nodemon": "^3.0.1", + "prettier": "^3.6.2", "supertest": "^6.3.3" }, "engines": { @@ -2106,6 +2109,19 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmmirror.com/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -4312,6 +4328,53 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", + "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -4576,6 +4639,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -7341,6 +7411,35 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz", @@ -8339,6 +8438,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/table": { "version": "6.9.0", "resolved": "https://registry.npmmirror.com/table/-/table-6.9.0.tgz", diff --git a/package.json b/package.json index 7643aca3..3323f475 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "Claude Code API relay service with multi-account management, OpenAI compatibility, and API key authentication", "main": "src/app.js", "scripts": { - "start": "node src/app.js", - "dev": "nodemon src/app.js", + "start": "npm run lint && node src/app.js", + "dev": "nodemon", "build:web": "cd web/admin-spa && npm run build", "install:web": "cd web/admin-spa && npm install", "update:pricing": "node scripts/update-model-pricing.js", @@ -25,7 +25,10 @@ "service:status": "node scripts/manage.js status", "service:logs": "node scripts/manage.js logs", "test": "jest", - "lint": "eslint src/**/*.js", + "lint": "eslint src/**/*.js cli/**/*.js scripts/**/*.js --fix", + "lint:check": "eslint src/**/*.js cli/**/*.js scripts/**/*.js", + "format": "prettier --write \"src/**/*.js\" \"cli/**/*.js\" \"scripts/**/*.js\"", + "format:check": "prettier --check \"src/**/*.js\" \"cli/**/*.js\" \"scripts/**/*.js\"", "docker:build": "docker build -t claude-relay-service .", "docker:up": "docker-compose up -d", "docker:down": "docker-compose down", @@ -69,8 +72,11 @@ "devDependencies": { "@types/node": "^20.8.9", "eslint": "^8.53.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", "jest": "^29.7.0", "nodemon": "^3.0.1", + "prettier": "^3.6.2", "supertest": "^6.3.3" }, "engines": { diff --git a/scripts/analyze-log-sessions.js b/scripts/analyze-log-sessions.js index dafcc70a..da76521e 100644 --- a/scripts/analyze-log-sessions.js +++ b/scripts/analyze-log-sessions.js @@ -5,71 +5,74 @@ * 用于恢复会话窗口数据 */ -const fs = require('fs'); -const path = require('path'); -const readline = require('readline'); -const zlib = require('zlib'); -const redis = require('../src/models/redis'); -const claudeAccountService = require('../src/services/claudeAccountService'); -const logger = require('../src/utils/logger'); +const fs = require('fs') +const path = require('path') +const readline = require('readline') +const zlib = require('zlib') +const redis = require('../src/models/redis') class LogSessionAnalyzer { constructor() { // 更新正则表达式以匹配实际的日志格式 - this.accountUsagePattern = /🎯 Using sticky session shared account: (.+?) \(([a-f0-9-]{36})\) for session ([a-f0-9]+)/; - this.processingPattern = /📡 Processing streaming API request with usage capture for key: (.+?), account: ([a-f0-9-]{36}), session: ([a-f0-9]+)/; - this.completedPattern = /🔗 ✅ Request completed in (\d+)ms for key: (.+)/; - this.usageRecordedPattern = /🔗 📊 Stream usage recorded \(real\) - Model: (.+?), Input: (\d+), Output: (\d+), Cache Create: (\d+), Cache Read: (\d+), Total: (\d+) tokens/; - this.timestampPattern = /\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/; - this.accounts = new Map(); - this.requestHistory = []; - this.sessions = new Map(); // 记录会话信息 + this.accountUsagePattern = + /🎯 Using sticky session shared account: (.+?) \(([a-f0-9-]{36})\) for session ([a-f0-9]+)/ + this.processingPattern = + /📡 Processing streaming API request with usage capture for key: (.+?), account: ([a-f0-9-]{36}), session: ([a-f0-9]+)/ + this.completedPattern = /🔗 ✅ Request completed in (\d+)ms for key: (.+)/ + this.usageRecordedPattern = + /🔗 📊 Stream usage recorded \(real\) - Model: (.+?), Input: (\d+), Output: (\d+), Cache Create: (\d+), Cache Read: (\d+), Total: (\d+) tokens/ + this.timestampPattern = /\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/ + this.accounts = new Map() + this.requestHistory = [] + this.sessions = new Map() // 记录会话信息 } // 解析时间戳 parseTimestamp(line) { - const match = line.match(this.timestampPattern); + const match = line.match(this.timestampPattern) if (match) { - return new Date(match[1]); + return new Date(match[1]) } - return null; + return null } // 分析单个日志文件 async analyzeLogFile(filePath) { - console.log(`📖 分析日志文件: ${filePath}`); - - let fileStream = fs.createReadStream(filePath); - + console.log(`📖 分析日志文件: ${filePath}`) + + let fileStream = fs.createReadStream(filePath) + // 如果是gz文件,需要先解压 if (filePath.endsWith('.gz')) { - console.log(` 🗜️ 检测到gz压缩文件,正在解压...`); - fileStream = fileStream.pipe(zlib.createGunzip()); + console.log(' 🗜️ 检测到gz压缩文件,正在解压...') + fileStream = fileStream.pipe(zlib.createGunzip()) } - + const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity - }); - - let lineCount = 0; - let requestCount = 0; - let usageCount = 0; - + }) + + let lineCount = 0 + let requestCount = 0 + let usageCount = 0 + for await (const line of rl) { - lineCount++; - + lineCount++ + // 解析时间戳 - const timestamp = this.parseTimestamp(line); - if (!timestamp) continue; - + const timestamp = this.parseTimestamp(line) + if (!timestamp) { + continue + } + // 查找账户使用记录 - const accountUsageMatch = line.match(this.accountUsagePattern); + const accountUsageMatch = line.match(this.accountUsagePattern) if (accountUsageMatch) { - const accountName = accountUsageMatch[1]; - const accountId = accountUsageMatch[2]; - const sessionId = accountUsageMatch[3]; - + const accountName = accountUsageMatch[1] + const accountId = accountUsageMatch[2] + const sessionId = accountUsageMatch[3] + if (!this.accounts.has(accountId)) { this.accounts.set(accountId, { accountId, @@ -79,27 +82,27 @@ class LogSessionAnalyzer { lastRequest: timestamp, totalRequests: 0, sessions: new Set() - }); + }) } - - const account = this.accounts.get(accountId); - account.sessions.add(sessionId); - + + const account = this.accounts.get(accountId) + account.sessions.add(sessionId) + if (timestamp < account.firstRequest) { - account.firstRequest = timestamp; + account.firstRequest = timestamp } if (timestamp > account.lastRequest) { - account.lastRequest = timestamp; + account.lastRequest = timestamp } } - + // 查找请求处理记录 - const processingMatch = line.match(this.processingPattern); + const processingMatch = line.match(this.processingPattern) if (processingMatch) { - const apiKeyName = processingMatch[1]; - const accountId = processingMatch[2]; - const sessionId = processingMatch[3]; - + const apiKeyName = processingMatch[1] + const accountId = processingMatch[2] + const sessionId = processingMatch[3] + if (!this.accounts.has(accountId)) { this.accounts.set(accountId, { accountId, @@ -109,25 +112,25 @@ class LogSessionAnalyzer { lastRequest: timestamp, totalRequests: 0, sessions: new Set() - }); + }) } - - const account = this.accounts.get(accountId); + + const account = this.accounts.get(accountId) account.requests.push({ timestamp, apiKeyName, sessionId, type: 'processing' - }); - - account.sessions.add(sessionId); - account.totalRequests++; - requestCount++; - + }) + + account.sessions.add(sessionId) + account.totalRequests++ + requestCount++ + if (timestamp > account.lastRequest) { - account.lastRequest = timestamp; + account.lastRequest = timestamp } - + // 记录到全局请求历史 this.requestHistory.push({ timestamp, @@ -135,36 +138,36 @@ class LogSessionAnalyzer { apiKeyName, sessionId, type: 'processing' - }); + }) } - + // 查找请求完成记录 - const completedMatch = line.match(this.completedPattern); + const completedMatch = line.match(this.completedPattern) if (completedMatch) { - const duration = parseInt(completedMatch[1]); - const apiKeyName = completedMatch[2]; - + const duration = parseInt(completedMatch[1]) + const apiKeyName = completedMatch[2] + // 记录到全局请求历史 this.requestHistory.push({ timestamp, apiKeyName, duration, type: 'completed' - }); + }) } - + // 查找使用统计记录 - const usageMatch = line.match(this.usageRecordedPattern); + const usageMatch = line.match(this.usageRecordedPattern) if (usageMatch) { - const model = usageMatch[1]; - const inputTokens = parseInt(usageMatch[2]); - const outputTokens = parseInt(usageMatch[3]); - const cacheCreateTokens = parseInt(usageMatch[4]); - const cacheReadTokens = parseInt(usageMatch[5]); - const totalTokens = parseInt(usageMatch[6]); - - usageCount++; - + const model = usageMatch[1] + const inputTokens = parseInt(usageMatch[2]) + const outputTokens = parseInt(usageMatch[3]) + const cacheCreateTokens = parseInt(usageMatch[4]) + const cacheReadTokens = parseInt(usageMatch[5]) + const totalTokens = parseInt(usageMatch[6]) + + usageCount++ + // 记录到全局请求历史 this.requestHistory.push({ timestamp, @@ -175,119 +178,119 @@ class LogSessionAnalyzer { cacheCreateTokens, cacheReadTokens, totalTokens - }); + }) } } - - console.log(` 📊 解析完成: ${lineCount} 行, 找到 ${requestCount} 个请求记录, ${usageCount} 个使用统计`); + + console.log( + ` 📊 解析完成: ${lineCount} 行, 找到 ${requestCount} 个请求记录, ${usageCount} 个使用统计` + ) } // 分析日志目录中的所有文件 async analyzeLogDirectory(logDir = './logs') { - console.log(`🔍 扫描日志目录: ${logDir}\n`); - + console.log(`🔍 扫描日志目录: ${logDir}\n`) + try { - const files = fs.readdirSync(logDir); + const files = fs.readdirSync(logDir) const logFiles = files - .filter(file => { - return file.includes('claude-relay') && ( - file.endsWith('.log') || - file.endsWith('.log.1') || - file.endsWith('.log.gz') || - file.match(/\.log\.\d+\.gz$/) || - file.match(/\.log\.\d+$/) - ); - }) + .filter( + (file) => + file.includes('claude-relay') && + (file.endsWith('.log') || + file.endsWith('.log.1') || + file.endsWith('.log.gz') || + file.match(/\.log\.\d+\.gz$/) || + file.match(/\.log\.\d+$/)) + ) .sort() - .reverse(); // 最新的文件优先 - + .reverse() // 最新的文件优先 + if (logFiles.length === 0) { - console.log('❌ 没有找到日志文件'); - return; + console.log('❌ 没有找到日志文件') + return } - - console.log(`📁 找到 ${logFiles.length} 个日志文件:`); - logFiles.forEach(file => console.log(` - ${file}`)); - console.log(''); - + + console.log(`📁 找到 ${logFiles.length} 个日志文件:`) + logFiles.forEach((file) => console.log(` - ${file}`)) + console.log('') + // 分析每个文件 for (const file of logFiles) { - const filePath = path.join(logDir, file); - await this.analyzeLogFile(filePath); + const filePath = path.join(logDir, file) + await this.analyzeLogFile(filePath) } - } catch (error) { - console.error(`❌ 读取日志目录失败: ${error.message}`); - throw error; + console.error(`❌ 读取日志目录失败: ${error.message}`) + throw error } } // 分析单个日志文件(支持直接传入文件路径) async analyzeSingleFile(filePath) { - console.log(`🔍 分析单个日志文件: ${filePath}\n`); - + console.log(`🔍 分析单个日志文件: ${filePath}\n`) + try { if (!fs.existsSync(filePath)) { - console.log('❌ 文件不存在'); - return; + console.log('❌ 文件不存在') + return } - - await this.analyzeLogFile(filePath); - + + await this.analyzeLogFile(filePath) } catch (error) { - console.error(`❌ 分析文件失败: ${error.message}`); - throw error; + console.error(`❌ 分析文件失败: ${error.message}`) + throw error } } // 计算会话窗口 calculateSessionWindow(requestTime) { - const hour = requestTime.getHours(); - const windowStartHour = Math.floor(hour / 5) * 5; - - const windowStart = new Date(requestTime); - windowStart.setHours(windowStartHour, 0, 0, 0); - - const windowEnd = new Date(windowStart); - windowEnd.setHours(windowEnd.getHours() + 5); - - return { windowStart, windowEnd }; + const hour = requestTime.getHours() + const windowStartHour = Math.floor(hour / 5) * 5 + + const windowStart = new Date(requestTime) + windowStart.setHours(windowStartHour, 0, 0, 0) + + const windowEnd = new Date(windowStart) + windowEnd.setHours(windowEnd.getHours() + 5) + + return { windowStart, windowEnd } } // 分析会话窗口 analyzeSessionWindows() { - console.log('🕐 分析会话窗口...\n'); - - const now = new Date(); - const results = []; - + console.log('🕐 分析会话窗口...\n') + + const now = new Date() + const results = [] + for (const [accountId, accountData] of this.accounts) { - const sessions = []; - const requests = accountData.requests.sort((a, b) => a.timestamp - b.timestamp); - + const requests = accountData.requests.sort((a, b) => a.timestamp - b.timestamp) + // 按会话窗口分组请求 - const windowGroups = new Map(); - + const windowGroups = new Map() + for (const request of requests) { - const { windowStart, windowEnd } = this.calculateSessionWindow(request.timestamp); - const windowKey = `${windowStart.getTime()}-${windowEnd.getTime()}`; - + const { windowStart, windowEnd } = this.calculateSessionWindow(request.timestamp) + const windowKey = `${windowStart.getTime()}-${windowEnd.getTime()}` + if (!windowGroups.has(windowKey)) { windowGroups.set(windowKey, { windowStart, windowEnd, requests: [], isActive: now >= windowStart && now < windowEnd - }); + }) } - - windowGroups.get(windowKey).requests.push(request); + + windowGroups.get(windowKey).requests.push(request) } - + // 转换为数组并排序 - const windowArray = Array.from(windowGroups.values()) - .sort((a, b) => b.windowStart - a.windowStart); // 最新的窗口优先 - + const windowArray = Array.from(windowGroups.values()).sort( + (a, b) => b.windowStart - a.windowStart + ) // 最新的窗口优先 + const result = { accountId, accountName: accountData.accountName, @@ -296,240 +299,247 @@ class LogSessionAnalyzer { lastRequest: accountData.lastRequest, sessions: accountData.sessions, windows: windowArray, - currentActiveWindow: windowArray.find(w => w.isActive) || null, + currentActiveWindow: windowArray.find((w) => w.isActive) || null, mostRecentWindow: windowArray[0] || null - }; - - results.push(result); + } + + results.push(result) } - - return results.sort((a, b) => b.lastRequest - a.lastRequest); + + return results.sort((a, b) => b.lastRequest - a.lastRequest) } // 显示分析结果 displayResults(results) { - console.log('📊 分析结果:\n'); - console.log('='.repeat(80)); - + console.log('📊 分析结果:\n') + console.log('='.repeat(80)) + for (const result of results) { - console.log(`🏢 账户: ${result.accountName || 'Unknown'} (${result.accountId})`); - console.log(` 总请求数: ${result.totalRequests}`); - console.log(` 会话数: ${result.sessions ? result.sessions.size : 0}`); - console.log(` 首次请求: ${result.firstRequest.toLocaleString()}`); - console.log(` 最后请求: ${result.lastRequest.toLocaleString()}`); - + console.log(`🏢 账户: ${result.accountName || 'Unknown'} (${result.accountId})`) + console.log(` 总请求数: ${result.totalRequests}`) + console.log(` 会话数: ${result.sessions ? result.sessions.size : 0}`) + console.log(` 首次请求: ${result.firstRequest.toLocaleString()}`) + console.log(` 最后请求: ${result.lastRequest.toLocaleString()}`) + if (result.currentActiveWindow) { - console.log(` ✅ 当前活跃窗口: ${result.currentActiveWindow.windowStart.toLocaleString()} - ${result.currentActiveWindow.windowEnd.toLocaleString()}`); - console.log(` 窗口内请求: ${result.currentActiveWindow.requests.length} 次`); - const progress = this.calculateWindowProgress(result.currentActiveWindow.windowStart, result.currentActiveWindow.windowEnd); - console.log(` 窗口进度: ${progress}%`); + console.log( + ` ✅ 当前活跃窗口: ${result.currentActiveWindow.windowStart.toLocaleString()} - ${result.currentActiveWindow.windowEnd.toLocaleString()}` + ) + console.log(` 窗口内请求: ${result.currentActiveWindow.requests.length} 次`) + const progress = this.calculateWindowProgress( + result.currentActiveWindow.windowStart, + result.currentActiveWindow.windowEnd + ) + console.log(` 窗口进度: ${progress}%`) } else if (result.mostRecentWindow) { - const window = result.mostRecentWindow; - console.log(` ⏰ 最近窗口(已过期): ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()}`); - console.log(` 窗口内请求: ${window.requests.length} 次`); - const hoursAgo = Math.round((new Date() - window.windowEnd) / (1000 * 60 * 60)); - console.log(` 过期时间: ${hoursAgo} 小时前`); + const window = result.mostRecentWindow + console.log( + ` ⏰ 最近窗口(已过期): ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()}` + ) + console.log(` 窗口内请求: ${window.requests.length} 次`) + const hoursAgo = Math.round((new Date() - window.windowEnd) / (1000 * 60 * 60)) + console.log(` 过期时间: ${hoursAgo} 小时前`) } else { - console.log(` ❌ 无会话窗口数据`); + console.log(' ❌ 无会话窗口数据') } - + // 显示最近几个窗口 if (result.windows.length > 1) { - console.log(` 📈 历史窗口: ${result.windows.length} 个`); - const recentWindows = result.windows.slice(0, 3); + console.log(` 📈 历史窗口: ${result.windows.length} 个`) + const recentWindows = result.windows.slice(0, 3) for (let i = 0; i < recentWindows.length; i++) { - const window = recentWindows[i]; - const status = window.isActive ? '活跃' : '已过期'; - console.log(` ${i + 1}. ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()} (${status}, ${window.requests.length}次请求)`); + const window = recentWindows[i] + const status = window.isActive ? '活跃' : '已过期' + console.log( + ` ${i + 1}. ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()} (${status}, ${window.requests.length}次请求)` + ) } } - + // 显示最近几个会话的API Key使用情况 - const accountData = this.accounts.get(result.accountId); + const accountData = this.accounts.get(result.accountId) if (accountData && accountData.requests && accountData.requests.length > 0) { - const recentRequests = accountData.requests.slice(-5); // 最近5个请求 - const apiKeyStats = {}; - + const apiKeyStats = {} + for (const req of accountData.requests) { if (!apiKeyStats[req.apiKeyName]) { - apiKeyStats[req.apiKeyName] = 0; + apiKeyStats[req.apiKeyName] = 0 } - apiKeyStats[req.apiKeyName]++; + apiKeyStats[req.apiKeyName]++ } - - console.log(` 🔑 API Key使用统计:`); + + console.log(' 🔑 API Key使用统计:') for (const [keyName, count] of Object.entries(apiKeyStats)) { - console.log(` - ${keyName}: ${count} 次`); + console.log(` - ${keyName}: ${count} 次`) } } - - console.log(''); + + console.log('') } - - console.log('='.repeat(80)); - console.log(`总计: ${results.length} 个账户, ${this.requestHistory.length} 个日志记录\n`); + + console.log('='.repeat(80)) + console.log(`总计: ${results.length} 个账户, ${this.requestHistory.length} 个日志记录\n`) } // 计算窗口进度百分比 calculateWindowProgress(windowStart, windowEnd) { - const now = new Date(); - const totalDuration = windowEnd.getTime() - windowStart.getTime(); - const elapsedTime = now.getTime() - windowStart.getTime(); - return Math.max(0, Math.min(100, Math.round((elapsedTime / totalDuration) * 100))); + const now = new Date() + const totalDuration = windowEnd.getTime() - windowStart.getTime() + const elapsedTime = now.getTime() - windowStart.getTime() + return Math.max(0, Math.min(100, Math.round((elapsedTime / totalDuration) * 100))) } // 更新Redis中的会话窗口数据 async updateRedisSessionWindows(results, dryRun = true) { if (dryRun) { - console.log('🧪 模拟模式 - 不会实际更新Redis数据\n'); + console.log('🧪 模拟模式 - 不会实际更新Redis数据\n') } else { - console.log('💾 更新Redis中的会话窗口数据...\n'); - await redis.connect(); + console.log('💾 更新Redis中的会话窗口数据...\n') + await redis.connect() } - - let updatedCount = 0; - let skippedCount = 0; - + + let updatedCount = 0 + let skippedCount = 0 + for (const result of results) { try { - const accountData = await redis.getClaudeAccount(result.accountId); - + const accountData = await redis.getClaudeAccount(result.accountId) + if (!accountData || Object.keys(accountData).length === 0) { - console.log(`⚠️ 账户 ${result.accountId} 在Redis中不存在,跳过`); - skippedCount++; - continue; + console.log(`⚠️ 账户 ${result.accountId} 在Redis中不存在,跳过`) + skippedCount++ + continue } - - console.log(`🔄 处理账户: ${accountData.name || result.accountId}`); - + + console.log(`🔄 处理账户: ${accountData.name || result.accountId}`) + // 确定要设置的会话窗口 - let targetWindow = null; - + let targetWindow = null + if (result.currentActiveWindow) { - targetWindow = result.currentActiveWindow; - console.log(` ✅ 使用当前活跃窗口: ${targetWindow.windowStart.toLocaleString()} - ${targetWindow.windowEnd.toLocaleString()}`); + targetWindow = result.currentActiveWindow + console.log( + ` ✅ 使用当前活跃窗口: ${targetWindow.windowStart.toLocaleString()} - ${targetWindow.windowEnd.toLocaleString()}` + ) } else if (result.mostRecentWindow) { - const window = result.mostRecentWindow; - const now = new Date(); - + const window = result.mostRecentWindow + const now = new Date() + // 如果最近窗口是在过去24小时内的,可以考虑恢复 - const hoursSinceWindow = (now - window.windowEnd) / (1000 * 60 * 60); - + const hoursSinceWindow = (now - window.windowEnd) / (1000 * 60 * 60) + if (hoursSinceWindow <= 24) { - console.log(` 🕐 最近窗口在24小时内,但已过期: ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()}`); - console.log(` ❌ 不恢复已过期窗口(${hoursSinceWindow.toFixed(1)}小时前过期)`); + console.log( + ` 🕐 最近窗口在24小时内,但已过期: ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()}` + ) + console.log(` ❌ 不恢复已过期窗口(${hoursSinceWindow.toFixed(1)}小时前过期)`) } else { - console.log(` ⏰ 最近窗口超过24小时前,不予恢复`); + console.log(' ⏰ 最近窗口超过24小时前,不予恢复') } } - + if (targetWindow && !dryRun) { // 更新Redis中的会话窗口数据 - accountData.sessionWindowStart = targetWindow.windowStart.toISOString(); - accountData.sessionWindowEnd = targetWindow.windowEnd.toISOString(); - accountData.lastUsedAt = result.lastRequest.toISOString(); - accountData.lastRequestTime = result.lastRequest.toISOString(); - - await redis.setClaudeAccount(result.accountId, accountData); - updatedCount++; - - console.log(` ✅ 已更新会话窗口数据`); + accountData.sessionWindowStart = targetWindow.windowStart.toISOString() + accountData.sessionWindowEnd = targetWindow.windowEnd.toISOString() + accountData.lastUsedAt = result.lastRequest.toISOString() + accountData.lastRequestTime = result.lastRequest.toISOString() + + await redis.setClaudeAccount(result.accountId, accountData) + updatedCount++ + + console.log(' ✅ 已更新会话窗口数据') } else if (targetWindow) { - updatedCount++; - console.log(` 🧪 [模拟] 将设置会话窗口: ${targetWindow.windowStart.toLocaleString()} - ${targetWindow.windowEnd.toLocaleString()}`); + updatedCount++ + console.log( + ` 🧪 [模拟] 将设置会话窗口: ${targetWindow.windowStart.toLocaleString()} - ${targetWindow.windowEnd.toLocaleString()}` + ) } else { - skippedCount++; - console.log(` ⏭️ 跳过(无有效窗口)`); + skippedCount++ + console.log(' ⏭️ 跳过(无有效窗口)') } - - console.log(''); - + + console.log('') } catch (error) { - console.error(`❌ 处理账户 ${result.accountId} 时出错: ${error.message}`); - skippedCount++; + console.error(`❌ 处理账户 ${result.accountId} 时出错: ${error.message}`) + skippedCount++ } } - + if (!dryRun) { - await redis.disconnect(); + await redis.disconnect() } - - console.log('📊 更新结果:'); - console.log(` ✅ 已更新: ${updatedCount}`); - console.log(` ⏭️ 已跳过: ${skippedCount}`); - console.log(` 📋 总计: ${results.length}`); + + console.log('📊 更新结果:') + console.log(` ✅ 已更新: ${updatedCount}`) + console.log(` ⏭️ 已跳过: ${skippedCount}`) + console.log(` 📋 总计: ${results.length}`) } // 主分析函数 async analyze(options = {}) { - const { - logDir = './logs', - singleFile = null, - updateRedis = false, - dryRun = true - } = options; - + const { logDir = './logs', singleFile = null, updateRedis = false, dryRun = true } = options + try { - console.log('🔍 Claude账户会话窗口分析工具\n'); - + console.log('🔍 Claude账户会话窗口分析工具\n') + // 分析日志文件 if (singleFile) { - await this.analyzeSingleFile(singleFile); + await this.analyzeSingleFile(singleFile) } else { - await this.analyzeLogDirectory(logDir); + await this.analyzeLogDirectory(logDir) } - + if (this.accounts.size === 0) { - console.log('❌ 没有找到任何Claude账户的请求记录'); - return; + console.log('❌ 没有找到任何Claude账户的请求记录') + return [] } - + // 分析会话窗口 - const results = this.analyzeSessionWindows(); - + const results = this.analyzeSessionWindows() + // 显示结果 - this.displayResults(results); - + this.displayResults(results) + // 更新Redis(如果需要) if (updateRedis) { - await this.updateRedisSessionWindows(results, dryRun); + await this.updateRedisSessionWindows(results, dryRun) } - - return results; - + + return results } catch (error) { - console.error('❌ 分析失败:', error); - throw error; + console.error('❌ 分析失败:', error) + throw error } } } // 命令行参数解析 function parseArgs() { - const args = process.argv.slice(2); + const args = process.argv.slice(2) const options = { logDir: './logs', singleFile: null, updateRedis: false, dryRun: true - }; - + } + for (const arg of args) { if (arg.startsWith('--log-dir=')) { - options.logDir = arg.split('=')[1]; + options.logDir = arg.split('=')[1] } else if (arg.startsWith('--file=')) { - options.singleFile = arg.split('=')[1]; + options.singleFile = arg.split('=')[1] } else if (arg === '--update-redis') { - options.updateRedis = true; + options.updateRedis = true } else if (arg === '--no-dry-run') { - options.dryRun = false; + options.dryRun = false } else if (arg === '--help' || arg === '-h') { - showHelp(); - process.exit(0); + showHelp() + process.exit(0) } } - - return options; + + return options } // 显示帮助信息 @@ -570,28 +580,27 @@ Claude账户会话窗口日志分析工具 - 窗口按整点对齐(如 05:00-10:00, 10:00-15:00) - 只有当前时间在窗口内的才被认为是活跃窗口 - 工具会自动识别并恢复活跃的会话窗口 -`); +`) } // 主函数 async function main() { try { - const options = parseArgs(); - - const analyzer = new LogSessionAnalyzer(); - await analyzer.analyze(options); - - console.log('🎉 分析完成'); - + const options = parseArgs() + + const analyzer = new LogSessionAnalyzer() + await analyzer.analyze(options) + + console.log('🎉 分析完成') } catch (error) { - console.error('💥 程序执行失败:', error); - process.exit(1); + console.error('💥 程序执行失败:', error) + process.exit(1) } } // 如果直接运行此脚本 if (require.main === module) { - main(); + main() } -module.exports = LogSessionAnalyzer; \ No newline at end of file +module.exports = LogSessionAnalyzer diff --git a/scripts/check-redis-keys.js b/scripts/check-redis-keys.js index 5201dfaf..93d70fb1 100644 --- a/scripts/check-redis-keys.js +++ b/scripts/check-redis-keys.js @@ -2,51 +2,52 @@ * 检查 Redis 中的所有键 */ -const redis = require('../src/models/redis'); +const redis = require('../src/models/redis') async function checkRedisKeys() { - console.log('🔍 检查 Redis 中的所有键...\n'); + console.log('🔍 检查 Redis 中的所有键...\n') try { // 确保 Redis 已连接 - await redis.connect(); - + await redis.connect() + // 获取所有键 - const allKeys = await redis.client.keys('*'); - console.log(`找到 ${allKeys.length} 个键\n`); - + const allKeys = await redis.client.keys('*') + console.log(`找到 ${allKeys.length} 个键\n`) + // 按类型分组 - const keysByType = {}; - - allKeys.forEach(key => { - const prefix = key.split(':')[0]; + const keysByType = {} + + allKeys.forEach((key) => { + const prefix = key.split(':')[0] if (!keysByType[prefix]) { - keysByType[prefix] = []; + keysByType[prefix] = [] } - keysByType[prefix].push(key); - }); - + keysByType[prefix].push(key) + }) + // 显示各类型的键 - Object.keys(keysByType).sort().forEach(type => { - console.log(`\n📁 ${type}: ${keysByType[type].length} 个`); - - // 显示前 5 个键作为示例 - const keysToShow = keysByType[type].slice(0, 5); - keysToShow.forEach(key => { - console.log(` - ${key}`); - }); - - if (keysByType[type].length > 5) { - console.log(` ... 还有 ${keysByType[type].length - 5} 个`); - } - }); - + Object.keys(keysByType) + .sort() + .forEach((type) => { + console.log(`\n📁 ${type}: ${keysByType[type].length} 个`) + + // 显示前 5 个键作为示例 + const keysToShow = keysByType[type].slice(0, 5) + keysToShow.forEach((key) => { + console.log(` - ${key}`) + }) + + if (keysByType[type].length > 5) { + console.log(` ... 还有 ${keysByType[type].length - 5} 个`) + } + }) } catch (error) { - console.error('❌ 错误:', error); - console.error(error.stack); + console.error('❌ 错误:', error) + console.error(error.stack) } finally { - process.exit(0); + process.exit(0) } } -checkRedisKeys(); \ No newline at end of file +checkRedisKeys() diff --git a/scripts/data-transfer-enhanced.js b/scripts/data-transfer-enhanced.js index 59390266..47b3920f 100644 --- a/scripts/data-transfer-enhanced.js +++ b/scripts/data-transfer-enhanced.js @@ -5,108 +5,116 @@ * 支持加密数据的处理 */ -const fs = require('fs').promises; -const crypto = require('crypto'); -const redis = require('../src/models/redis'); -const logger = require('../src/utils/logger'); -const readline = require('readline'); -const config = require('../config/config'); +const fs = require('fs').promises +const crypto = require('crypto') +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') +const readline = require('readline') +const config = require('../config/config') // 解析命令行参数 -const args = process.argv.slice(2); -const command = args[0]; -const params = {}; +const args = process.argv.slice(2) +const command = args[0] +const params = {} -args.slice(1).forEach(arg => { - const [key, value] = arg.split('='); - params[key.replace('--', '')] = value || true; -}); +args.slice(1).forEach((arg) => { + const [key, value] = arg.split('=') + params[key.replace('--', '')] = value || true +}) // 创建 readline 接口 const rl = readline.createInterface({ input: process.stdin, output: process.stdout -}); +}) async function askConfirmation(question) { return new Promise((resolve) => { - rl.question(question + ' (yes/no): ', (answer) => { - resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y'); - }); - }); + rl.question(`${question} (yes/no): `, (answer) => { + resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y') + }) + }) } // Claude 账户解密函数 function decryptClaudeData(encryptedData) { - if (!encryptedData || !config.security.encryptionKey) return encryptedData; - + if (!encryptedData || !config.security.encryptionKey) { + return encryptedData + } + try { if (encryptedData.includes(':')) { - const parts = encryptedData.split(':'); - const key = crypto.scryptSync(config.security.encryptionKey, 'salt', 32); - const iv = Buffer.from(parts[0], 'hex'); - const encrypted = parts[1]; - - const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); - let decrypted = decipher.update(encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; + const parts = encryptedData.split(':') + const key = crypto.scryptSync(config.security.encryptionKey, 'salt', 32) + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted } - return encryptedData; + return encryptedData } catch (error) { - logger.warn(`⚠️ Failed to decrypt data: ${error.message}`); - return encryptedData; + logger.warn(`⚠️ Failed to decrypt data: ${error.message}`) + return encryptedData } } // Gemini 账户解密函数 function decryptGeminiData(encryptedData) { - if (!encryptedData || !config.security.encryptionKey) return encryptedData; - + if (!encryptedData || !config.security.encryptionKey) { + return encryptedData + } + try { if (encryptedData.includes(':')) { - const parts = encryptedData.split(':'); - const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-salt', 32); - const iv = Buffer.from(parts[0], 'hex'); - const encrypted = parts[1]; - - const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); - let decrypted = decipher.update(encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; + const parts = encryptedData.split(':') + const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-salt', 32) + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted } - return encryptedData; + return encryptedData } catch (error) { - logger.warn(`⚠️ Failed to decrypt data: ${error.message}`); - return encryptedData; + logger.warn(`⚠️ Failed to decrypt data: ${error.message}`) + return encryptedData } } // 数据加密函数(用于导入) function encryptClaudeData(data) { - if (!data || !config.security.encryptionKey) return data; - - const key = crypto.scryptSync(config.security.encryptionKey, 'salt', 32); - const iv = crypto.randomBytes(16); - - const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); - let encrypted = cipher.update(data, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - - return iv.toString('hex') + ':' + encrypted; + if (!data || !config.security.encryptionKey) { + return data + } + + const key = crypto.scryptSync(config.security.encryptionKey, 'salt', 32) + const iv = crypto.randomBytes(16) + + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv) + let encrypted = cipher.update(data, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return `${iv.toString('hex')}:${encrypted}` } function encryptGeminiData(data) { - if (!data || !config.security.encryptionKey) return data; - - const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-salt', 32); - const iv = crypto.randomBytes(16); - - const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); - let encrypted = cipher.update(data, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - - return iv.toString('hex') + ':' + encrypted; + if (!data || !config.security.encryptionKey) { + return data + } + + const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-salt', 32) + const iv = crypto.randomBytes(16) + + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv) + let encrypted = cipher.update(data, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return `${iv.toString('hex')}:${encrypted}` } // 导出使用统计数据 @@ -118,143 +126,149 @@ async function exportUsageStats(keyId) { monthly: {}, hourly: {}, models: {} - }; - - // 导出总统计 - const totalKey = `usage:${keyId}`; - const totalData = await redis.client.hgetall(totalKey); - if (totalData && Object.keys(totalData).length > 0) { - stats.total = totalData; } - + + // 导出总统计 + const totalKey = `usage:${keyId}` + const totalData = await redis.client.hgetall(totalKey) + if (totalData && Object.keys(totalData).length > 0) { + stats.total = totalData + } + // 导出每日统计(最近30天) - const today = new Date(); + const today = new Date() for (let i = 0; i < 30; i++) { - const date = new Date(today); - date.setDate(date.getDate() - i); - const dateStr = date.toISOString().split('T')[0]; - const dailyKey = `usage:daily:${keyId}:${dateStr}`; - - const dailyData = await redis.client.hgetall(dailyKey); + const date = new Date(today) + date.setDate(date.getDate() - i) + const dateStr = date.toISOString().split('T')[0] + const dailyKey = `usage:daily:${keyId}:${dateStr}` + + const dailyData = await redis.client.hgetall(dailyKey) if (dailyData && Object.keys(dailyData).length > 0) { - stats.daily[dateStr] = dailyData; + stats.daily[dateStr] = dailyData } } - + // 导出每月统计(最近12个月) for (let i = 0; i < 12; i++) { - const date = new Date(today); - date.setMonth(date.getMonth() - i); - const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; - const monthlyKey = `usage:monthly:${keyId}:${monthStr}`; - - const monthlyData = await redis.client.hgetall(monthlyKey); + const date = new Date(today) + date.setMonth(date.getMonth() - i) + const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + const monthlyKey = `usage:monthly:${keyId}:${monthStr}` + + const monthlyData = await redis.client.hgetall(monthlyKey) if (monthlyData && Object.keys(monthlyData).length > 0) { - stats.monthly[monthStr] = monthlyData; + stats.monthly[monthStr] = monthlyData } } - + // 导出小时统计(最近24小时) for (let i = 0; i < 24; i++) { - const date = new Date(today); - date.setHours(date.getHours() - i); - const dateStr = date.toISOString().split('T')[0]; - const hour = String(date.getHours()).padStart(2, '0'); - const hourKey = `${dateStr}:${hour}`; - const hourlyKey = `usage:hourly:${keyId}:${hourKey}`; - - const hourlyData = await redis.client.hgetall(hourlyKey); + const date = new Date(today) + date.setHours(date.getHours() - i) + const dateStr = date.toISOString().split('T')[0] + const hour = String(date.getHours()).padStart(2, '0') + const hourKey = `${dateStr}:${hour}` + const hourlyKey = `usage:hourly:${keyId}:${hourKey}` + + const hourlyData = await redis.client.hgetall(hourlyKey) if (hourlyData && Object.keys(hourlyData).length > 0) { - stats.hourly[hourKey] = hourlyData; + stats.hourly[hourKey] = hourlyData } } - + // 导出模型统计 // 每日模型统计 - const modelDailyPattern = `usage:${keyId}:model:daily:*`; - const modelDailyKeys = await redis.client.keys(modelDailyPattern); + const modelDailyPattern = `usage:${keyId}:model:daily:*` + const modelDailyKeys = await redis.client.keys(modelDailyPattern) for (const key of modelDailyKeys) { - const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/); + const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) if (match) { - const model = match[1]; - const date = match[2]; - const data = await redis.client.hgetall(key); + const model = match[1] + const date = match[2] + const data = await redis.client.hgetall(key) if (data && Object.keys(data).length > 0) { - if (!stats.models[model]) stats.models[model] = { daily: {}, monthly: {} }; - stats.models[model].daily[date] = data; + if (!stats.models[model]) { + stats.models[model] = { daily: {}, monthly: {} } + } + stats.models[model].daily[date] = data } } } - + // 每月模型统计 - const modelMonthlyPattern = `usage:${keyId}:model:monthly:*`; - const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern); + const modelMonthlyPattern = `usage:${keyId}:model:monthly:*` + const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern) for (const key of modelMonthlyKeys) { - const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/); + const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/) if (match) { - const model = match[1]; - const month = match[2]; - const data = await redis.client.hgetall(key); + const model = match[1] + const month = match[2] + const data = await redis.client.hgetall(key) if (data && Object.keys(data).length > 0) { - if (!stats.models[model]) stats.models[model] = { daily: {}, monthly: {} }; - stats.models[model].monthly[month] = data; + if (!stats.models[model]) { + stats.models[model] = { daily: {}, monthly: {} } + } + stats.models[model].monthly[month] = data } } } - - return stats; + + return stats } catch (error) { - logger.warn(`⚠️ Failed to export usage stats for ${keyId}: ${error.message}`); - return null; + logger.warn(`⚠️ Failed to export usage stats for ${keyId}: ${error.message}`) + return null } } // 导入使用统计数据 async function importUsageStats(keyId, stats) { try { - if (!stats) return; - - const pipeline = redis.client.pipeline(); - let importCount = 0; - + if (!stats) { + return + } + + const pipeline = redis.client.pipeline() + let importCount = 0 + // 导入总统计 if (stats.total && Object.keys(stats.total).length > 0) { for (const [field, value] of Object.entries(stats.total)) { - pipeline.hset(`usage:${keyId}`, field, value); + pipeline.hset(`usage:${keyId}`, field, value) } - importCount++; + importCount++ } - + // 导入每日统计 if (stats.daily) { for (const [date, data] of Object.entries(stats.daily)) { for (const [field, value] of Object.entries(data)) { - pipeline.hset(`usage:daily:${keyId}:${date}`, field, value); + pipeline.hset(`usage:daily:${keyId}:${date}`, field, value) } - importCount++; + importCount++ } } - + // 导入每月统计 if (stats.monthly) { for (const [month, data] of Object.entries(stats.monthly)) { for (const [field, value] of Object.entries(data)) { - pipeline.hset(`usage:monthly:${keyId}:${month}`, field, value); + pipeline.hset(`usage:monthly:${keyId}:${month}`, field, value) } - importCount++; + importCount++ } } - + // 导入小时统计 if (stats.hourly) { for (const [hour, data] of Object.entries(stats.hourly)) { for (const [field, value] of Object.entries(data)) { - pipeline.hset(`usage:hourly:${keyId}:${hour}`, field, value); + pipeline.hset(`usage:hourly:${keyId}:${hour}`, field, value) } - importCount++; + importCount++ } } - + // 导入模型统计 if (stats.models) { for (const [model, modelStats] of Object.entries(stats.models)) { @@ -262,305 +276,347 @@ async function importUsageStats(keyId, stats) { if (modelStats.daily) { for (const [date, data] of Object.entries(modelStats.daily)) { for (const [field, value] of Object.entries(data)) { - pipeline.hset(`usage:${keyId}:model:daily:${model}:${date}`, field, value); + pipeline.hset(`usage:${keyId}:model:daily:${model}:${date}`, field, value) } - importCount++; + importCount++ } } - + // 每月模型统计 if (modelStats.monthly) { for (const [month, data] of Object.entries(modelStats.monthly)) { for (const [field, value] of Object.entries(data)) { - pipeline.hset(`usage:${keyId}:model:monthly:${model}:${month}`, field, value); + pipeline.hset(`usage:${keyId}:model:monthly:${model}:${month}`, field, value) } - importCount++; + importCount++ } } } } - - await pipeline.exec(); - logger.info(` 📊 Imported ${importCount} usage stat entries for API Key ${keyId}`); - + + await pipeline.exec() + logger.info(` 📊 Imported ${importCount} usage stat entries for API Key ${keyId}`) } catch (error) { - logger.warn(`⚠️ Failed to import usage stats for ${keyId}: ${error.message}`); + logger.warn(`⚠️ Failed to import usage stats for ${keyId}: ${error.message}`) } } // 数据脱敏函数 function sanitizeData(data, type) { - const sanitized = { ...data }; - + const sanitized = { ...data } + switch (type) { case 'apikey': if (sanitized.apiKey) { - sanitized.apiKey = sanitized.apiKey.substring(0, 10) + '...[REDACTED]'; + sanitized.apiKey = `${sanitized.apiKey.substring(0, 10)}...[REDACTED]` } - break; - + break + case 'claude_account': - if (sanitized.email) sanitized.email = '[REDACTED]'; - if (sanitized.password) sanitized.password = '[REDACTED]'; - if (sanitized.accessToken) sanitized.accessToken = '[REDACTED]'; - if (sanitized.refreshToken) sanitized.refreshToken = '[REDACTED]'; - if (sanitized.claudeAiOauth) sanitized.claudeAiOauth = '[REDACTED]'; - if (sanitized.proxyPassword) sanitized.proxyPassword = '[REDACTED]'; - break; - + if (sanitized.email) { + sanitized.email = '[REDACTED]' + } + if (sanitized.password) { + sanitized.password = '[REDACTED]' + } + if (sanitized.accessToken) { + sanitized.accessToken = '[REDACTED]' + } + if (sanitized.refreshToken) { + sanitized.refreshToken = '[REDACTED]' + } + if (sanitized.claudeAiOauth) { + sanitized.claudeAiOauth = '[REDACTED]' + } + if (sanitized.proxyPassword) { + sanitized.proxyPassword = '[REDACTED]' + } + break + case 'gemini_account': - if (sanitized.geminiOauth) sanitized.geminiOauth = '[REDACTED]'; - if (sanitized.accessToken) sanitized.accessToken = '[REDACTED]'; - if (sanitized.refreshToken) sanitized.refreshToken = '[REDACTED]'; - if (sanitized.proxyPassword) sanitized.proxyPassword = '[REDACTED]'; - break; - + if (sanitized.geminiOauth) { + sanitized.geminiOauth = '[REDACTED]' + } + if (sanitized.accessToken) { + sanitized.accessToken = '[REDACTED]' + } + if (sanitized.refreshToken) { + sanitized.refreshToken = '[REDACTED]' + } + if (sanitized.proxyPassword) { + sanitized.proxyPassword = '[REDACTED]' + } + break + case 'admin': - if (sanitized.password) sanitized.password = '[REDACTED]'; - break; + if (sanitized.password) { + sanitized.password = '[REDACTED]' + } + break } - - return sanitized; + + return sanitized } // 导出数据 async function exportData() { try { - const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json`; - const types = params.types ? params.types.split(',') : ['all']; - const shouldSanitize = params.sanitize === true; - const shouldDecrypt = params.decrypt !== false; // 默认解密 - - logger.info('🔄 Starting data export...'); - logger.info(`📁 Output file: ${outputFile}`); - logger.info(`📋 Data types: ${types.join(', ')}`); - logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`); - logger.info(`🔓 Decrypt data: ${shouldDecrypt ? 'YES' : 'NO'}`); - - await redis.connect(); - logger.success('✅ Connected to Redis'); - - const exportData = { + const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json` + const types = params.types ? params.types.split(',') : ['all'] + const shouldSanitize = params.sanitize === true + const shouldDecrypt = params.decrypt !== false // 默认解密 + + logger.info('🔄 Starting data export...') + logger.info(`📁 Output file: ${outputFile}`) + logger.info(`📋 Data types: ${types.join(', ')}`) + logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`) + logger.info(`🔓 Decrypt data: ${shouldDecrypt ? 'YES' : 'NO'}`) + + await redis.connect() + logger.success('✅ Connected to Redis') + + const exportDataObj = { metadata: { version: '2.0', exportDate: new Date().toISOString(), sanitized: shouldSanitize, decrypted: shouldDecrypt, - types: types + types }, data: {} - }; - + } + // 导出 API Keys if (types.includes('all') || types.includes('apikeys')) { - logger.info('📤 Exporting API Keys...'); - const keys = await redis.client.keys('apikey:*'); - const apiKeys = []; - + logger.info('📤 Exporting API Keys...') + const keys = await redis.client.keys('apikey:*') + const apiKeys = [] + for (const key of keys) { - if (key === 'apikey:hash_map') continue; - - const data = await redis.client.hgetall(key); + if (key === 'apikey:hash_map') { + continue + } + + const data = await redis.client.hgetall(key) if (data && Object.keys(data).length > 0) { // 获取该 API Key 的 ID - const keyId = data.id; - + const keyId = data.id + // 导出使用统计数据 if (keyId && (types.includes('all') || types.includes('stats'))) { - data.usageStats = await exportUsageStats(keyId); + data.usageStats = await exportUsageStats(keyId) } - - apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data); + + apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data) } } - - exportData.data.apiKeys = apiKeys; - logger.success(`✅ Exported ${apiKeys.length} API Keys`); + + exportDataObj.data.apiKeys = apiKeys + logger.success(`✅ Exported ${apiKeys.length} API Keys`) } - + // 导出 Claude 账户 if (types.includes('all') || types.includes('accounts')) { - logger.info('📤 Exporting Claude accounts...'); - const keys = await redis.client.keys('claude:account:*'); - logger.info(`Found ${keys.length} Claude account keys in Redis`); - const accounts = []; - + logger.info('📤 Exporting Claude accounts...') + const keys = await redis.client.keys('claude:account:*') + logger.info(`Found ${keys.length} Claude account keys in Redis`) + const accounts = [] + for (const key of keys) { - const data = await redis.client.hgetall(key); - + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { // 解密敏感字段 if (shouldDecrypt && !shouldSanitize) { - if (data.email) data.email = decryptClaudeData(data.email); - if (data.password) data.password = decryptClaudeData(data.password); - if (data.accessToken) data.accessToken = decryptClaudeData(data.accessToken); - if (data.refreshToken) data.refreshToken = decryptClaudeData(data.refreshToken); + if (data.email) { + data.email = decryptClaudeData(data.email) + } + if (data.password) { + data.password = decryptClaudeData(data.password) + } + if (data.accessToken) { + data.accessToken = decryptClaudeData(data.accessToken) + } + if (data.refreshToken) { + data.refreshToken = decryptClaudeData(data.refreshToken) + } if (data.claudeAiOauth) { - const decrypted = decryptClaudeData(data.claudeAiOauth); + const decrypted = decryptClaudeData(data.claudeAiOauth) try { - data.claudeAiOauth = JSON.parse(decrypted); + data.claudeAiOauth = JSON.parse(decrypted) } catch (e) { - data.claudeAiOauth = decrypted; + data.claudeAiOauth = decrypted } } } - - accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data); + + accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data) } } - - exportData.data.claudeAccounts = accounts; - logger.success(`✅ Exported ${accounts.length} Claude accounts`); - + + exportDataObj.data.claudeAccounts = accounts + logger.success(`✅ Exported ${accounts.length} Claude accounts`) + // 导出 Gemini 账户 - logger.info('📤 Exporting Gemini accounts...'); - const geminiKeys = await redis.client.keys('gemini_account:*'); - logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`); - const geminiAccounts = []; - + logger.info('📤 Exporting Gemini accounts...') + const geminiKeys = await redis.client.keys('gemini_account:*') + logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`) + const geminiAccounts = [] + for (const key of geminiKeys) { - const data = await redis.client.hgetall(key); - + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { // 解密敏感字段 if (shouldDecrypt && !shouldSanitize) { if (data.geminiOauth) { - const decrypted = decryptGeminiData(data.geminiOauth); + const decrypted = decryptGeminiData(data.geminiOauth) try { - data.geminiOauth = JSON.parse(decrypted); + data.geminiOauth = JSON.parse(decrypted) } catch (e) { - data.geminiOauth = decrypted; + data.geminiOauth = decrypted } } - if (data.accessToken) data.accessToken = decryptGeminiData(data.accessToken); - if (data.refreshToken) data.refreshToken = decryptGeminiData(data.refreshToken); + if (data.accessToken) { + data.accessToken = decryptGeminiData(data.accessToken) + } + if (data.refreshToken) { + data.refreshToken = decryptGeminiData(data.refreshToken) + } } - - geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data); + + geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data) } } - - exportData.data.geminiAccounts = geminiAccounts; - logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`); + + exportDataObj.data.geminiAccounts = geminiAccounts + logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`) } - + // 导出管理员 if (types.includes('all') || types.includes('admins')) { - logger.info('📤 Exporting admins...'); - const keys = await redis.client.keys('admin:*'); - const admins = []; - + logger.info('📤 Exporting admins...') + const keys = await redis.client.keys('admin:*') + const admins = [] + for (const key of keys) { - if (key.includes('admin_username:')) continue; - - const data = await redis.client.hgetall(key); + if (key.includes('admin_username:')) { + continue + } + + const data = await redis.client.hgetall(key) if (data && Object.keys(data).length > 0) { - admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data); + admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data) } } - - exportData.data.admins = admins; - logger.success(`✅ Exported ${admins.length} admins`); + + exportDataObj.data.admins = admins + logger.success(`✅ Exported ${admins.length} admins`) } - + // 导出全局模型统计(如果需要) if (types.includes('all') || types.includes('stats')) { - logger.info('📤 Exporting global model statistics...'); + logger.info('📤 Exporting global model statistics...') const globalStats = { daily: {}, monthly: {}, hourly: {} - }; - + } + // 导出全局每日模型统计 - const globalDailyPattern = 'usage:model:daily:*'; - const globalDailyKeys = await redis.client.keys(globalDailyPattern); + const globalDailyPattern = 'usage:model:daily:*' + const globalDailyKeys = await redis.client.keys(globalDailyPattern) for (const key of globalDailyKeys) { - const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/); + const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) if (match) { - const model = match[1]; - const date = match[2]; - const data = await redis.client.hgetall(key); + const model = match[1] + const date = match[2] + const data = await redis.client.hgetall(key) if (data && Object.keys(data).length > 0) { - if (!globalStats.daily[date]) globalStats.daily[date] = {}; - globalStats.daily[date][model] = data; + if (!globalStats.daily[date]) { + globalStats.daily[date] = {} + } + globalStats.daily[date][model] = data } } } - + // 导出全局每月模型统计 - const globalMonthlyPattern = 'usage:model:monthly:*'; - const globalMonthlyKeys = await redis.client.keys(globalMonthlyPattern); + const globalMonthlyPattern = 'usage:model:monthly:*' + const globalMonthlyKeys = await redis.client.keys(globalMonthlyPattern) for (const key of globalMonthlyKeys) { - const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/); + const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/) if (match) { - const model = match[1]; - const month = match[2]; - const data = await redis.client.hgetall(key); + const model = match[1] + const month = match[2] + const data = await redis.client.hgetall(key) if (data && Object.keys(data).length > 0) { - if (!globalStats.monthly[month]) globalStats.monthly[month] = {}; - globalStats.monthly[month][model] = data; + if (!globalStats.monthly[month]) { + globalStats.monthly[month] = {} + } + globalStats.monthly[month][model] = data } } } - + // 导出全局每小时模型统计 - const globalHourlyPattern = 'usage:model:hourly:*'; - const globalHourlyKeys = await redis.client.keys(globalHourlyPattern); + const globalHourlyPattern = 'usage:model:hourly:*' + const globalHourlyKeys = await redis.client.keys(globalHourlyPattern) for (const key of globalHourlyKeys) { - const match = key.match(/usage:model:hourly:(.+):(\d{4}-\d{2}-\d{2}:\d{2})$/); + const match = key.match(/usage:model:hourly:(.+):(\d{4}-\d{2}-\d{2}:\d{2})$/) if (match) { - const model = match[1]; - const hour = match[2]; - const data = await redis.client.hgetall(key); + const model = match[1] + const hour = match[2] + const data = await redis.client.hgetall(key) if (data && Object.keys(data).length > 0) { - if (!globalStats.hourly[hour]) globalStats.hourly[hour] = {}; - globalStats.hourly[hour][model] = data; + if (!globalStats.hourly[hour]) { + globalStats.hourly[hour] = {} + } + globalStats.hourly[hour][model] = data } } } - - exportData.data.globalModelStats = globalStats; - logger.success(`✅ Exported global model statistics`); + + exportDataObj.data.globalModelStats = globalStats + logger.success('✅ Exported global model statistics') } - + // 写入文件 - await fs.writeFile(outputFile, JSON.stringify(exportData, null, 2)); - + await fs.writeFile(outputFile, JSON.stringify(exportDataObj, null, 2)) + // 显示导出摘要 - console.log('\n' + '='.repeat(60)); - console.log('✅ Export Complete!'); - console.log('='.repeat(60)); - console.log(`Output file: ${outputFile}`); - console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`); - - if (exportData.data.apiKeys) { - console.log(`API Keys: ${exportData.data.apiKeys.length}`); + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Export Complete!') + console.log('='.repeat(60)) + console.log(`Output file: ${outputFile}`) + console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`) + + if (exportDataObj.data.apiKeys) { + console.log(`API Keys: ${exportDataObj.data.apiKeys.length}`) } - if (exportData.data.claudeAccounts) { - console.log(`Claude Accounts: ${exportData.data.claudeAccounts.length}`); + if (exportDataObj.data.claudeAccounts) { + console.log(`Claude Accounts: ${exportDataObj.data.claudeAccounts.length}`) } - if (exportData.data.geminiAccounts) { - console.log(`Gemini Accounts: ${exportData.data.geminiAccounts.length}`); + if (exportDataObj.data.geminiAccounts) { + console.log(`Gemini Accounts: ${exportDataObj.data.geminiAccounts.length}`) } - if (exportData.data.admins) { - console.log(`Admins: ${exportData.data.admins.length}`); + if (exportDataObj.data.admins) { + console.log(`Admins: ${exportDataObj.data.admins.length}`) } - console.log('='.repeat(60)); - + console.log('='.repeat(60)) + if (shouldSanitize) { - logger.warn('⚠️ Sensitive data has been sanitized in this export.'); + logger.warn('⚠️ Sensitive data has been sanitized in this export.') } if (shouldDecrypt) { - logger.info('🔓 Encrypted data has been decrypted for portability.'); + logger.info('🔓 Encrypted data has been decrypted for portability.') } - } catch (error) { - logger.error('💥 Export failed:', error); - process.exit(1); + logger.error('💥 Export failed:', error) + process.exit(1) } finally { - await redis.disconnect(); - rl.close(); + await redis.disconnect() + rl.close() } } @@ -608,387 +664,412 @@ Examples: # Import with force overwrite node scripts/data-transfer-enhanced.js import --input=backup.json --force -`); +`) } // 导入数据 async function importData() { try { - const inputFile = params.input; + const inputFile = params.input if (!inputFile) { - logger.error('❌ Please specify input file with --input=filename.json'); - process.exit(1); + logger.error('❌ Please specify input file with --input=filename.json') + process.exit(1) } - - const forceOverwrite = params.force === true; - const skipConflicts = params['skip-conflicts'] === true; - - logger.info('🔄 Starting data import...'); - logger.info(`📁 Input file: ${inputFile}`); - logger.info(`⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : (skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT')}`); - + + const forceOverwrite = params.force === true + const skipConflicts = params['skip-conflicts'] === true + + logger.info('🔄 Starting data import...') + logger.info(`📁 Input file: ${inputFile}`) + logger.info( + `⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT'}` + ) + // 读取文件 - const fileContent = await fs.readFile(inputFile, 'utf8'); - const importData = JSON.parse(fileContent); - + const fileContent = await fs.readFile(inputFile, 'utf8') + const importDataObj = JSON.parse(fileContent) + // 验证文件格式 - if (!importData.metadata || !importData.data) { - logger.error('❌ Invalid backup file format'); - process.exit(1); + if (!importDataObj.metadata || !importDataObj.data) { + logger.error('❌ Invalid backup file format') + process.exit(1) } - - logger.info(`📅 Backup date: ${importData.metadata.exportDate}`); - logger.info(`🔒 Sanitized: ${importData.metadata.sanitized ? 'YES' : 'NO'}`); - logger.info(`🔓 Decrypted: ${importData.metadata.decrypted ? 'YES' : 'NO'}`); - - if (importData.metadata.sanitized) { - logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!'); - const proceed = await askConfirmation('Continue with sanitized data?'); + + logger.info(`📅 Backup date: ${importDataObj.metadata.exportDate}`) + logger.info(`🔒 Sanitized: ${importDataObj.metadata.sanitized ? 'YES' : 'NO'}`) + logger.info(`🔓 Decrypted: ${importDataObj.metadata.decrypted ? 'YES' : 'NO'}`) + + if (importDataObj.metadata.sanitized) { + logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!') + const proceed = await askConfirmation('Continue with sanitized data?') if (!proceed) { - logger.info('❌ Import cancelled'); - return; + logger.info('❌ Import cancelled') + return } } - + // 显示导入摘要 - console.log('\n' + '='.repeat(60)); - console.log('📋 Import Summary:'); - console.log('='.repeat(60)); - if (importData.data.apiKeys) { - console.log(`API Keys to import: ${importData.data.apiKeys.length}`); + console.log(`\n${'='.repeat(60)}`) + console.log('📋 Import Summary:') + console.log('='.repeat(60)) + if (importDataObj.data.apiKeys) { + console.log(`API Keys to import: ${importDataObj.data.apiKeys.length}`) } - if (importData.data.claudeAccounts) { - console.log(`Claude Accounts to import: ${importData.data.claudeAccounts.length}`); + if (importDataObj.data.claudeAccounts) { + console.log(`Claude Accounts to import: ${importDataObj.data.claudeAccounts.length}`) } - if (importData.data.geminiAccounts) { - console.log(`Gemini Accounts to import: ${importData.data.geminiAccounts.length}`); + if (importDataObj.data.geminiAccounts) { + console.log(`Gemini Accounts to import: ${importDataObj.data.geminiAccounts.length}`) } - if (importData.data.admins) { - console.log(`Admins to import: ${importData.data.admins.length}`); + if (importDataObj.data.admins) { + console.log(`Admins to import: ${importDataObj.data.admins.length}`) } - console.log('='.repeat(60) + '\n'); - + console.log(`${'='.repeat(60)}\n`) + // 确认导入 - const confirmed = await askConfirmation('⚠️ Proceed with import?'); + const confirmed = await askConfirmation('⚠️ Proceed with import?') if (!confirmed) { - logger.info('❌ Import cancelled'); - return; + logger.info('❌ Import cancelled') + return } - + // 连接 Redis - await redis.connect(); - logger.success('✅ Connected to Redis'); - + await redis.connect() + logger.success('✅ Connected to Redis') + const stats = { imported: 0, skipped: 0, errors: 0 - }; - + } + // 导入 API Keys - if (importData.data.apiKeys) { - logger.info('\n📥 Importing API Keys...'); - for (const apiKey of importData.data.apiKeys) { + if (importDataObj.data.apiKeys) { + logger.info('\n📥 Importing API Keys...') + for (const apiKey of importDataObj.data.apiKeys) { try { - const exists = await redis.client.exists(`apikey:${apiKey.id}`); - + const exists = await redis.client.exists(`apikey:${apiKey.id}`) + if (exists && !forceOverwrite) { if (skipConflicts) { - logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`); - stats.skipped++; - continue; + logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`) + stats.skipped++ + continue } else { - const overwrite = await askConfirmation(`API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?`); + const overwrite = await askConfirmation( + `API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?` + ) if (!overwrite) { - stats.skipped++; - continue; + stats.skipped++ + continue } } } - + // 保存使用统计数据以便单独导入 - const usageStats = apiKey.usageStats; - + const { usageStats } = apiKey + // 从apiKey对象中删除usageStats字段,避免存储到主键中 - const apiKeyData = { ...apiKey }; - delete apiKeyData.usageStats; - + const apiKeyData = { ...apiKey } + delete apiKeyData.usageStats + // 使用 hset 存储到哈希表 - const pipeline = redis.client.pipeline(); + const pipeline = redis.client.pipeline() for (const [field, value] of Object.entries(apiKeyData)) { - pipeline.hset(`apikey:${apiKey.id}`, field, value); + pipeline.hset(`apikey:${apiKey.id}`, field, value) } - await pipeline.exec(); - + await pipeline.exec() + // 更新哈希映射 - if (apiKey.apiKey && !importData.metadata.sanitized) { - await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id); + if (apiKey.apiKey && !importDataObj.metadata.sanitized) { + await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id) } - + // 导入使用统计数据 if (usageStats) { - await importUsageStats(apiKey.id, usageStats); + await importUsageStats(apiKey.id, usageStats) } - - logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`); - stats.imported++; + + logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`) + stats.imported++ } catch (error) { - logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message); - stats.errors++; + logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message) + stats.errors++ } } } - + // 导入 Claude 账户 - if (importData.data.claudeAccounts) { - logger.info('\n📥 Importing Claude accounts...'); - for (const account of importData.data.claudeAccounts) { + if (importDataObj.data.claudeAccounts) { + logger.info('\n📥 Importing Claude accounts...') + for (const account of importDataObj.data.claudeAccounts) { try { - const exists = await redis.client.exists(`claude:account:${account.id}`); - + const exists = await redis.client.exists(`claude:account:${account.id}`) + if (exists && !forceOverwrite) { if (skipConflicts) { - logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`); - stats.skipped++; - continue; + logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`) + stats.skipped++ + continue } else { - const overwrite = await askConfirmation(`Claude account "${account.name}" (${account.id}) exists. Overwrite?`); + const overwrite = await askConfirmation( + `Claude account "${account.name}" (${account.id}) exists. Overwrite?` + ) if (!overwrite) { - stats.skipped++; - continue; + stats.skipped++ + continue } } } - + // 复制账户数据以避免修改原始数据 - const accountData = { ...account }; - + const accountData = { ...account } + // 如果数据已解密且不是脱敏数据,需要重新加密 - if (importData.metadata.decrypted && !importData.metadata.sanitized) { - logger.info(`🔐 Re-encrypting sensitive data for Claude account: ${account.name}`); - - if (accountData.email) accountData.email = encryptClaudeData(accountData.email); - if (accountData.password) accountData.password = encryptClaudeData(accountData.password); - if (accountData.accessToken) accountData.accessToken = encryptClaudeData(accountData.accessToken); - if (accountData.refreshToken) accountData.refreshToken = encryptClaudeData(accountData.refreshToken); + if (importDataObj.metadata.decrypted && !importDataObj.metadata.sanitized) { + logger.info(`🔐 Re-encrypting sensitive data for Claude account: ${account.name}`) + + if (accountData.email) { + accountData.email = encryptClaudeData(accountData.email) + } + if (accountData.password) { + accountData.password = encryptClaudeData(accountData.password) + } + if (accountData.accessToken) { + accountData.accessToken = encryptClaudeData(accountData.accessToken) + } + if (accountData.refreshToken) { + accountData.refreshToken = encryptClaudeData(accountData.refreshToken) + } if (accountData.claudeAiOauth) { // 如果是对象,先序列化再加密 - const oauthStr = typeof accountData.claudeAiOauth === 'object' ? - JSON.stringify(accountData.claudeAiOauth) : accountData.claudeAiOauth; - accountData.claudeAiOauth = encryptClaudeData(oauthStr); + const oauthStr = + typeof accountData.claudeAiOauth === 'object' + ? JSON.stringify(accountData.claudeAiOauth) + : accountData.claudeAiOauth + accountData.claudeAiOauth = encryptClaudeData(oauthStr) } } - + // 使用 hset 存储到哈希表 - const pipeline = redis.client.pipeline(); + const pipeline = redis.client.pipeline() for (const [field, value] of Object.entries(accountData)) { if (field === 'claudeAiOauth' && typeof value === 'object') { // 确保对象被序列化 - pipeline.hset(`claude:account:${account.id}`, field, JSON.stringify(value)); + pipeline.hset(`claude:account:${account.id}`, field, JSON.stringify(value)) } else { - pipeline.hset(`claude:account:${account.id}`, field, value); + pipeline.hset(`claude:account:${account.id}`, field, value) } } - await pipeline.exec(); - - logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`); - stats.imported++; + await pipeline.exec() + + logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`) + stats.imported++ } catch (error) { - logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message); - stats.errors++; + logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message) + stats.errors++ } } } - + // 导入 Gemini 账户 - if (importData.data.geminiAccounts) { - logger.info('\n📥 Importing Gemini accounts...'); - for (const account of importData.data.geminiAccounts) { + if (importDataObj.data.geminiAccounts) { + logger.info('\n📥 Importing Gemini accounts...') + for (const account of importDataObj.data.geminiAccounts) { try { - const exists = await redis.client.exists(`gemini_account:${account.id}`); - + const exists = await redis.client.exists(`gemini_account:${account.id}`) + if (exists && !forceOverwrite) { if (skipConflicts) { - logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`); - stats.skipped++; - continue; + logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`) + stats.skipped++ + continue } else { - const overwrite = await askConfirmation(`Gemini account "${account.name}" (${account.id}) exists. Overwrite?`); + const overwrite = await askConfirmation( + `Gemini account "${account.name}" (${account.id}) exists. Overwrite?` + ) if (!overwrite) { - stats.skipped++; - continue; + stats.skipped++ + continue } } } - + // 复制账户数据以避免修改原始数据 - const accountData = { ...account }; - + const accountData = { ...account } + // 如果数据已解密且不是脱敏数据,需要重新加密 - if (importData.metadata.decrypted && !importData.metadata.sanitized) { - logger.info(`🔐 Re-encrypting sensitive data for Gemini account: ${account.name}`); - + if (importDataObj.metadata.decrypted && !importDataObj.metadata.sanitized) { + logger.info(`🔐 Re-encrypting sensitive data for Gemini account: ${account.name}`) + if (accountData.geminiOauth) { - const oauthStr = typeof accountData.geminiOauth === 'object' ? - JSON.stringify(accountData.geminiOauth) : accountData.geminiOauth; - accountData.geminiOauth = encryptGeminiData(oauthStr); + const oauthStr = + typeof accountData.geminiOauth === 'object' + ? JSON.stringify(accountData.geminiOauth) + : accountData.geminiOauth + accountData.geminiOauth = encryptGeminiData(oauthStr) + } + if (accountData.accessToken) { + accountData.accessToken = encryptGeminiData(accountData.accessToken) + } + if (accountData.refreshToken) { + accountData.refreshToken = encryptGeminiData(accountData.refreshToken) } - if (accountData.accessToken) accountData.accessToken = encryptGeminiData(accountData.accessToken); - if (accountData.refreshToken) accountData.refreshToken = encryptGeminiData(accountData.refreshToken); } - + // 使用 hset 存储到哈希表 - const pipeline = redis.client.pipeline(); + const pipeline = redis.client.pipeline() for (const [field, value] of Object.entries(accountData)) { - pipeline.hset(`gemini_account:${account.id}`, field, value); + pipeline.hset(`gemini_account:${account.id}`, field, value) } - await pipeline.exec(); - - logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`); - stats.imported++; + await pipeline.exec() + + logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`) + stats.imported++ } catch (error) { - logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message); - stats.errors++; + logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message) + stats.errors++ } } } - + // 导入管理员账户 - if (importData.data.admins) { - logger.info('\n📥 Importing admins...'); - for (const admin of importData.data.admins) { + if (importDataObj.data.admins) { + logger.info('\n📥 Importing admins...') + for (const admin of importDataObj.data.admins) { try { - const exists = await redis.client.exists(`admin:${admin.id}`); - + const exists = await redis.client.exists(`admin:${admin.id}`) + if (exists && !forceOverwrite) { if (skipConflicts) { - logger.warn(`⏭️ Skipped existing admin: ${admin.username} (${admin.id})`); - stats.skipped++; - continue; + logger.warn(`⏭️ Skipped existing admin: ${admin.username} (${admin.id})`) + stats.skipped++ + continue } else { - const overwrite = await askConfirmation(`Admin "${admin.username}" (${admin.id}) exists. Overwrite?`); + const overwrite = await askConfirmation( + `Admin "${admin.username}" (${admin.id}) exists. Overwrite?` + ) if (!overwrite) { - stats.skipped++; - continue; + stats.skipped++ + continue } } } - + // 使用 hset 存储到哈希表 - const pipeline = redis.client.pipeline(); + const pipeline = redis.client.pipeline() for (const [field, value] of Object.entries(admin)) { - pipeline.hset(`admin:${admin.id}`, field, value); + pipeline.hset(`admin:${admin.id}`, field, value) } - await pipeline.exec(); - + await pipeline.exec() + // 更新用户名映射 - await redis.client.set(`admin_username:${admin.username}`, admin.id); - - logger.success(`✅ Imported admin: ${admin.username} (${admin.id})`); - stats.imported++; + await redis.client.set(`admin_username:${admin.username}`, admin.id) + + logger.success(`✅ Imported admin: ${admin.username} (${admin.id})`) + stats.imported++ } catch (error) { - logger.error(`❌ Failed to import admin ${admin.id}:`, error.message); - stats.errors++; + logger.error(`❌ Failed to import admin ${admin.id}:`, error.message) + stats.errors++ } } } - + // 导入全局模型统计 - if (importData.data.globalModelStats) { - logger.info('\n📥 Importing global model statistics...'); + if (importDataObj.data.globalModelStats) { + logger.info('\n📥 Importing global model statistics...') try { - const globalStats = importData.data.globalModelStats; - const pipeline = redis.client.pipeline(); - let globalStatCount = 0; - + const globalStats = importDataObj.data.globalModelStats + const pipeline = redis.client.pipeline() + let globalStatCount = 0 + // 导入每日统计 if (globalStats.daily) { for (const [date, models] of Object.entries(globalStats.daily)) { for (const [model, data] of Object.entries(models)) { for (const [field, value] of Object.entries(data)) { - pipeline.hset(`usage:model:daily:${model}:${date}`, field, value); + pipeline.hset(`usage:model:daily:${model}:${date}`, field, value) } - globalStatCount++; + globalStatCount++ } } } - + // 导入每月统计 if (globalStats.monthly) { for (const [month, models] of Object.entries(globalStats.monthly)) { for (const [model, data] of Object.entries(models)) { for (const [field, value] of Object.entries(data)) { - pipeline.hset(`usage:model:monthly:${model}:${month}`, field, value); + pipeline.hset(`usage:model:monthly:${model}:${month}`, field, value) } - globalStatCount++; + globalStatCount++ } } } - + // 导入每小时统计 if (globalStats.hourly) { for (const [hour, models] of Object.entries(globalStats.hourly)) { for (const [model, data] of Object.entries(models)) { for (const [field, value] of Object.entries(data)) { - pipeline.hset(`usage:model:hourly:${model}:${hour}`, field, value); + pipeline.hset(`usage:model:hourly:${model}:${hour}`, field, value) } - globalStatCount++; + globalStatCount++ } } } - - await pipeline.exec(); - logger.success(`✅ Imported ${globalStatCount} global model stat entries`); - stats.imported += globalStatCount; + + await pipeline.exec() + logger.success(`✅ Imported ${globalStatCount} global model stat entries`) + stats.imported += globalStatCount } catch (error) { - logger.error(`❌ Failed to import global model stats:`, error.message); - stats.errors++; + logger.error('❌ Failed to import global model stats:', error.message) + stats.errors++ } } - + // 显示导入结果 - console.log('\n' + '='.repeat(60)); - console.log('✅ Import Complete!'); - console.log('='.repeat(60)); - console.log(`Successfully imported: ${stats.imported}`); - console.log(`Skipped: ${stats.skipped}`); - console.log(`Errors: ${stats.errors}`); - console.log('='.repeat(60)); - + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Import Complete!') + console.log('='.repeat(60)) + console.log(`Successfully imported: ${stats.imported}`) + console.log(`Skipped: ${stats.skipped}`) + console.log(`Errors: ${stats.errors}`) + console.log('='.repeat(60)) } catch (error) { - logger.error('💥 Import failed:', error); - process.exit(1); + logger.error('💥 Import failed:', error) + process.exit(1) } finally { - await redis.disconnect(); - rl.close(); + await redis.disconnect() + rl.close() } } // 主函数 async function main() { if (!command || command === '--help' || command === 'help') { - showHelp(); - process.exit(0); + showHelp() + process.exit(0) } - + switch (command) { case 'export': - await exportData(); - break; - + await exportData() + break + case 'import': - await importData(); - break; - + await importData() + break + default: - logger.error(`❌ Unknown command: ${command}`); - showHelp(); - process.exit(1); + logger.error(`❌ Unknown command: ${command}`) + showHelp() + process.exit(1) } } // 运行 -main().catch(error => { - logger.error('💥 Unexpected error:', error); - process.exit(1); -}); \ No newline at end of file +main().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/data-transfer.js b/scripts/data-transfer.js index 86cc9f0b..e6f13f66 100644 --- a/scripts/data-transfer.js +++ b/scripts/data-transfer.js @@ -2,11 +2,11 @@ /** * 数据导出/导入工具 - * + * * 使用方法: * 导出: node scripts/data-transfer.js export --output=backup.json [options] * 导入: node scripts/data-transfer.js import --input=backup.json [options] - * + * * 选项: * --types: 要导出/导入的数据类型(apikeys,accounts,admins,all) * --sanitize: 导出时脱敏敏感数据 @@ -14,437 +14,445 @@ * --skip-conflicts: 导入时跳过冲突的数据 */ -const fs = require('fs').promises; -const path = require('path'); -const crypto = require('crypto'); -const redis = require('../src/models/redis'); -const logger = require('../src/utils/logger'); -const readline = require('readline'); +const fs = require('fs').promises +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') +const readline = require('readline') // 解析命令行参数 -const args = process.argv.slice(2); -const command = args[0]; -const params = {}; +const args = process.argv.slice(2) +const command = args[0] +const params = {} -args.slice(1).forEach(arg => { - const [key, value] = arg.split('='); - params[key.replace('--', '')] = value || true; -}); +args.slice(1).forEach((arg) => { + const [key, value] = arg.split('=') + params[key.replace('--', '')] = value || true +}) // 创建 readline 接口 const rl = readline.createInterface({ input: process.stdin, output: process.stdout -}); +}) async function askConfirmation(question) { return new Promise((resolve) => { - rl.question(question + ' (yes/no): ', (answer) => { - resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y'); - }); - }); + rl.question(`${question} (yes/no): `, (answer) => { + resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y') + }) + }) } // 数据脱敏函数 function sanitizeData(data, type) { - const sanitized = { ...data }; - + const sanitized = { ...data } + switch (type) { case 'apikey': // 隐藏 API Key 的大部分内容 if (sanitized.apiKey) { - sanitized.apiKey = sanitized.apiKey.substring(0, 10) + '...[REDACTED]'; + sanitized.apiKey = `${sanitized.apiKey.substring(0, 10)}...[REDACTED]` } - break; - + break + case 'claude_account': case 'gemini_account': // 隐藏 OAuth tokens if (sanitized.accessToken) { - sanitized.accessToken = '[REDACTED]'; + sanitized.accessToken = '[REDACTED]' } if (sanitized.refreshToken) { - sanitized.refreshToken = '[REDACTED]'; + sanitized.refreshToken = '[REDACTED]' } if (sanitized.claudeAiOauth) { - sanitized.claudeAiOauth = '[REDACTED]'; + sanitized.claudeAiOauth = '[REDACTED]' } // 隐藏代理密码 if (sanitized.proxyPassword) { - sanitized.proxyPassword = '[REDACTED]'; + sanitized.proxyPassword = '[REDACTED]' } - break; - + break + case 'admin': // 隐藏管理员密码 if (sanitized.password) { - sanitized.password = '[REDACTED]'; + sanitized.password = '[REDACTED]' } - break; + break } - - return sanitized; + + return sanitized } // 导出数据 async function exportData() { try { - const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json`; - const types = params.types ? params.types.split(',') : ['all']; - const shouldSanitize = params.sanitize === true; - - logger.info('🔄 Starting data export...'); - logger.info(`📁 Output file: ${outputFile}`); - logger.info(`📋 Data types: ${types.join(', ')}`); - logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`); - + const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json` + const types = params.types ? params.types.split(',') : ['all'] + const shouldSanitize = params.sanitize === true + + logger.info('🔄 Starting data export...') + logger.info(`📁 Output file: ${outputFile}`) + logger.info(`📋 Data types: ${types.join(', ')}`) + logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`) + // 连接 Redis - await redis.connect(); - logger.success('✅ Connected to Redis'); - - const exportData = { + await redis.connect() + logger.success('✅ Connected to Redis') + + const exportDataObj = { metadata: { version: '1.0', exportDate: new Date().toISOString(), sanitized: shouldSanitize, - types: types + types }, data: {} - }; - + } + // 导出 API Keys if (types.includes('all') || types.includes('apikeys')) { - logger.info('📤 Exporting API Keys...'); - const keys = await redis.client.keys('apikey:*'); - const apiKeys = []; - + logger.info('📤 Exporting API Keys...') + const keys = await redis.client.keys('apikey:*') + const apiKeys = [] + for (const key of keys) { - if (key === 'apikey:hash_map') continue; - + if (key === 'apikey:hash_map') { + continue + } + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 - const data = await redis.client.hgetall(key); - + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { - apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data); + apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data) } } - - exportData.data.apiKeys = apiKeys; - logger.success(`✅ Exported ${apiKeys.length} API Keys`); + + exportDataObj.data.apiKeys = apiKeys + logger.success(`✅ Exported ${apiKeys.length} API Keys`) } - + // 导出 Claude 账户 if (types.includes('all') || types.includes('accounts')) { - logger.info('📤 Exporting Claude accounts...'); + logger.info('📤 Exporting Claude accounts...') // 注意:Claude 账户使用 claude:account: 前缀,不是 claude_account: - const keys = await redis.client.keys('claude:account:*'); - logger.info(`Found ${keys.length} Claude account keys in Redis`); - const accounts = []; - + const keys = await redis.client.keys('claude:account:*') + logger.info(`Found ${keys.length} Claude account keys in Redis`) + const accounts = [] + for (const key of keys) { // 使用 hgetall 而不是 get,因为数据存储在哈希表中 - const data = await redis.client.hgetall(key); - + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { // 解析 JSON 字段(如果存在) if (data.claudeAiOauth) { try { - data.claudeAiOauth = JSON.parse(data.claudeAiOauth); + data.claudeAiOauth = JSON.parse(data.claudeAiOauth) } catch (e) { // 保持原样 } } - accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data); + accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data) } } - - exportData.data.claudeAccounts = accounts; - logger.success(`✅ Exported ${accounts.length} Claude accounts`); - + + exportDataObj.data.claudeAccounts = accounts + logger.success(`✅ Exported ${accounts.length} Claude accounts`) + // 导出 Gemini 账户 - logger.info('📤 Exporting Gemini accounts...'); - const geminiKeys = await redis.client.keys('gemini_account:*'); - logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`); - const geminiAccounts = []; - + logger.info('📤 Exporting Gemini accounts...') + const geminiKeys = await redis.client.keys('gemini_account:*') + logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`) + const geminiAccounts = [] + for (const key of geminiKeys) { // 使用 hgetall 而不是 get,因为数据存储在哈希表中 - const data = await redis.client.hgetall(key); - + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { - geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data); + geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data) } } - - exportData.data.geminiAccounts = geminiAccounts; - logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`); + + exportDataObj.data.geminiAccounts = geminiAccounts + logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`) } - + // 导出管理员 if (types.includes('all') || types.includes('admins')) { - logger.info('📤 Exporting admins...'); - const keys = await redis.client.keys('admin:*'); - const admins = []; - + logger.info('📤 Exporting admins...') + const keys = await redis.client.keys('admin:*') + const admins = [] + for (const key of keys) { - if (key.includes('admin_username:')) continue; - + if (key.includes('admin_username:')) { + continue + } + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 - const data = await redis.client.hgetall(key); - + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { - admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data); + admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data) } } - - exportData.data.admins = admins; - logger.success(`✅ Exported ${admins.length} admins`); + + exportDataObj.data.admins = admins + logger.success(`✅ Exported ${admins.length} admins`) } - + // 写入文件 - await fs.writeFile(outputFile, JSON.stringify(exportData, null, 2)); - + await fs.writeFile(outputFile, JSON.stringify(exportData, null, 2)) + // 显示导出摘要 - console.log('\n' + '='.repeat(60)); - console.log('✅ Export Complete!'); - console.log('='.repeat(60)); - console.log(`Output file: ${outputFile}`); - console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`); - - if (exportData.data.apiKeys) { - console.log(`API Keys: ${exportData.data.apiKeys.length}`); + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Export Complete!') + console.log('='.repeat(60)) + console.log(`Output file: ${outputFile}`) + console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`) + + if (exportDataObj.data.apiKeys) { + console.log(`API Keys: ${exportDataObj.data.apiKeys.length}`) } - if (exportData.data.claudeAccounts) { - console.log(`Claude Accounts: ${exportData.data.claudeAccounts.length}`); + if (exportDataObj.data.claudeAccounts) { + console.log(`Claude Accounts: ${exportDataObj.data.claudeAccounts.length}`) } - if (exportData.data.geminiAccounts) { - console.log(`Gemini Accounts: ${exportData.data.geminiAccounts.length}`); + if (exportDataObj.data.geminiAccounts) { + console.log(`Gemini Accounts: ${exportDataObj.data.geminiAccounts.length}`) } - if (exportData.data.admins) { - console.log(`Admins: ${exportData.data.admins.length}`); + if (exportDataObj.data.admins) { + console.log(`Admins: ${exportDataObj.data.admins.length}`) } - console.log('='.repeat(60)); - + console.log('='.repeat(60)) + if (shouldSanitize) { - logger.warn('⚠️ Sensitive data has been sanitized in this export.'); + logger.warn('⚠️ Sensitive data has been sanitized in this export.') } - } catch (error) { - logger.error('💥 Export failed:', error); - process.exit(1); + logger.error('💥 Export failed:', error) + process.exit(1) } finally { - await redis.disconnect(); - rl.close(); + await redis.disconnect() + rl.close() } } // 导入数据 async function importData() { try { - const inputFile = params.input; + const inputFile = params.input if (!inputFile) { - logger.error('❌ Please specify input file with --input=filename.json'); - process.exit(1); + logger.error('❌ Please specify input file with --input=filename.json') + process.exit(1) } - - const forceOverwrite = params.force === true; - const skipConflicts = params['skip-conflicts'] === true; - - logger.info('🔄 Starting data import...'); - logger.info(`📁 Input file: ${inputFile}`); - logger.info(`⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : (skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT')}`); - + + const forceOverwrite = params.force === true + const skipConflicts = params['skip-conflicts'] === true + + logger.info('🔄 Starting data import...') + logger.info(`📁 Input file: ${inputFile}`) + logger.info( + `⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT'}` + ) + // 读取文件 - const fileContent = await fs.readFile(inputFile, 'utf8'); - const importData = JSON.parse(fileContent); - + const fileContent = await fs.readFile(inputFile, 'utf8') + const importDataObj = JSON.parse(fileContent) + // 验证文件格式 - if (!importData.metadata || !importData.data) { - logger.error('❌ Invalid backup file format'); - process.exit(1); + if (!importDataObj.metadata || !importDataObj.data) { + logger.error('❌ Invalid backup file format') + process.exit(1) } - - logger.info(`📅 Backup date: ${importData.metadata.exportDate}`); - logger.info(`🔒 Sanitized: ${importData.metadata.sanitized ? 'YES' : 'NO'}`); - - if (importData.metadata.sanitized) { - logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!'); - const proceed = await askConfirmation('Continue with sanitized data?'); + + logger.info(`📅 Backup date: ${importDataObj.metadata.exportDate}`) + logger.info(`🔒 Sanitized: ${importDataObj.metadata.sanitized ? 'YES' : 'NO'}`) + + if (importDataObj.metadata.sanitized) { + logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!') + const proceed = await askConfirmation('Continue with sanitized data?') if (!proceed) { - logger.info('❌ Import cancelled'); - return; + logger.info('❌ Import cancelled') + return } } - + // 显示导入摘要 - console.log('\n' + '='.repeat(60)); - console.log('📋 Import Summary:'); - console.log('='.repeat(60)); - if (importData.data.apiKeys) { - console.log(`API Keys to import: ${importData.data.apiKeys.length}`); + console.log(`\n${'='.repeat(60)}`) + console.log('📋 Import Summary:') + console.log('='.repeat(60)) + if (importDataObj.data.apiKeys) { + console.log(`API Keys to import: ${importDataObj.data.apiKeys.length}`) } - if (importData.data.claudeAccounts) { - console.log(`Claude Accounts to import: ${importData.data.claudeAccounts.length}`); + if (importDataObj.data.claudeAccounts) { + console.log(`Claude Accounts to import: ${importDataObj.data.claudeAccounts.length}`) } - if (importData.data.geminiAccounts) { - console.log(`Gemini Accounts to import: ${importData.data.geminiAccounts.length}`); + if (importDataObj.data.geminiAccounts) { + console.log(`Gemini Accounts to import: ${importDataObj.data.geminiAccounts.length}`) } - if (importData.data.admins) { - console.log(`Admins to import: ${importData.data.admins.length}`); + if (importDataObj.data.admins) { + console.log(`Admins to import: ${importDataObj.data.admins.length}`) } - console.log('='.repeat(60) + '\n'); - + console.log(`${'='.repeat(60)}\n`) + // 确认导入 - const confirmed = await askConfirmation('⚠️ Proceed with import?'); + const confirmed = await askConfirmation('⚠️ Proceed with import?') if (!confirmed) { - logger.info('❌ Import cancelled'); - return; + logger.info('❌ Import cancelled') + return } - + // 连接 Redis - await redis.connect(); - logger.success('✅ Connected to Redis'); - + await redis.connect() + logger.success('✅ Connected to Redis') + const stats = { imported: 0, skipped: 0, errors: 0 - }; - + } + // 导入 API Keys - if (importData.data.apiKeys) { - logger.info('\n📥 Importing API Keys...'); - for (const apiKey of importData.data.apiKeys) { + if (importDataObj.data.apiKeys) { + logger.info('\n📥 Importing API Keys...') + for (const apiKey of importDataObj.data.apiKeys) { try { - const exists = await redis.client.exists(`apikey:${apiKey.id}`); - + const exists = await redis.client.exists(`apikey:${apiKey.id}`) + if (exists && !forceOverwrite) { if (skipConflicts) { - logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`); - stats.skipped++; - continue; + logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`) + stats.skipped++ + continue } else { - const overwrite = await askConfirmation(`API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?`); + const overwrite = await askConfirmation( + `API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?` + ) if (!overwrite) { - stats.skipped++; - continue; + stats.skipped++ + continue } } } - + // 使用 hset 存储到哈希表 - const pipeline = redis.client.pipeline(); + const pipeline = redis.client.pipeline() for (const [field, value] of Object.entries(apiKey)) { - pipeline.hset(`apikey:${apiKey.id}`, field, value); + pipeline.hset(`apikey:${apiKey.id}`, field, value) } - await pipeline.exec(); - + await pipeline.exec() + // 更新哈希映射 - if (apiKey.apiKey && !importData.metadata.sanitized) { - await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id); + if (apiKey.apiKey && !importDataObj.metadata.sanitized) { + await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id) } - - logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`); - stats.imported++; + + logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`) + stats.imported++ } catch (error) { - logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message); - stats.errors++; + logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message) + stats.errors++ } } } - + // 导入 Claude 账户 - if (importData.data.claudeAccounts) { - logger.info('\n📥 Importing Claude accounts...'); - for (const account of importData.data.claudeAccounts) { + if (importDataObj.data.claudeAccounts) { + logger.info('\n📥 Importing Claude accounts...') + for (const account of importDataObj.data.claudeAccounts) { try { - const exists = await redis.client.exists(`claude_account:${account.id}`); - + const exists = await redis.client.exists(`claude_account:${account.id}`) + if (exists && !forceOverwrite) { if (skipConflicts) { - logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`); - stats.skipped++; - continue; + logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`) + stats.skipped++ + continue } else { - const overwrite = await askConfirmation(`Claude account "${account.name}" (${account.id}) exists. Overwrite?`); + const overwrite = await askConfirmation( + `Claude account "${account.name}" (${account.id}) exists. Overwrite?` + ) if (!overwrite) { - stats.skipped++; - continue; + stats.skipped++ + continue } } } - + // 使用 hset 存储到哈希表 - const pipeline = redis.client.pipeline(); + const pipeline = redis.client.pipeline() for (const [field, value] of Object.entries(account)) { // 如果是对象,需要序列化 if (field === 'claudeAiOauth' && typeof value === 'object') { - pipeline.hset(`claude_account:${account.id}`, field, JSON.stringify(value)); + pipeline.hset(`claude_account:${account.id}`, field, JSON.stringify(value)) } else { - pipeline.hset(`claude_account:${account.id}`, field, value); + pipeline.hset(`claude_account:${account.id}`, field, value) } } - await pipeline.exec(); - logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`); - stats.imported++; + await pipeline.exec() + logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`) + stats.imported++ } catch (error) { - logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message); - stats.errors++; + logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message) + stats.errors++ } } } - + // 导入 Gemini 账户 - if (importData.data.geminiAccounts) { - logger.info('\n📥 Importing Gemini accounts...'); - for (const account of importData.data.geminiAccounts) { + if (importDataObj.data.geminiAccounts) { + logger.info('\n📥 Importing Gemini accounts...') + for (const account of importDataObj.data.geminiAccounts) { try { - const exists = await redis.client.exists(`gemini_account:${account.id}`); - + const exists = await redis.client.exists(`gemini_account:${account.id}`) + if (exists && !forceOverwrite) { if (skipConflicts) { - logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`); - stats.skipped++; - continue; + logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`) + stats.skipped++ + continue } else { - const overwrite = await askConfirmation(`Gemini account "${account.name}" (${account.id}) exists. Overwrite?`); + const overwrite = await askConfirmation( + `Gemini account "${account.name}" (${account.id}) exists. Overwrite?` + ) if (!overwrite) { - stats.skipped++; - continue; + stats.skipped++ + continue } } } - + // 使用 hset 存储到哈希表 - const pipeline = redis.client.pipeline(); + const pipeline = redis.client.pipeline() for (const [field, value] of Object.entries(account)) { - pipeline.hset(`gemini_account:${account.id}`, field, value); + pipeline.hset(`gemini_account:${account.id}`, field, value) } - await pipeline.exec(); - logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`); - stats.imported++; + await pipeline.exec() + logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`) + stats.imported++ } catch (error) { - logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message); - stats.errors++; + logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message) + stats.errors++ } } } - + // 显示导入结果 - console.log('\n' + '='.repeat(60)); - console.log('✅ Import Complete!'); - console.log('='.repeat(60)); - console.log(`Successfully imported: ${stats.imported}`); - console.log(`Skipped: ${stats.skipped}`); - console.log(`Errors: ${stats.errors}`); - console.log('='.repeat(60)); - + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Import Complete!') + console.log('='.repeat(60)) + console.log(`Successfully imported: ${stats.imported}`) + console.log(`Skipped: ${stats.skipped}`) + console.log(`Errors: ${stats.errors}`) + console.log('='.repeat(60)) } catch (error) { - logger.error('💥 Import failed:', error); - process.exit(1); + logger.error('💥 Import failed:', error) + process.exit(1) } finally { - await redis.disconnect(); - rl.close(); + await redis.disconnect() + rl.close() } } @@ -484,34 +492,34 @@ Examples: # Export specific data types node scripts/data-transfer.js export --types=apikeys,accounts --output=prod-data.json -`); +`) } // 主函数 async function main() { if (!command || command === '--help' || command === 'help') { - showHelp(); - process.exit(0); + showHelp() + process.exit(0) } - + switch (command) { case 'export': - await exportData(); - break; - + await exportData() + break + case 'import': - await importData(); - break; - + await importData() + break + default: - logger.error(`❌ Unknown command: ${command}`); - showHelp(); - process.exit(1); + logger.error(`❌ Unknown command: ${command}`) + showHelp() + process.exit(1) } } // 运行 -main().catch(error => { - logger.error('💥 Unexpected error:', error); - process.exit(1); -}); \ No newline at end of file +main().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/debug-redis-keys.js b/scripts/debug-redis-keys.js index 22b25050..4cd7e9d3 100644 --- a/scripts/debug-redis-keys.js +++ b/scripts/debug-redis-keys.js @@ -5,19 +5,19 @@ * 用于查看 Redis 中存储的所有键和数据结构 */ -const redis = require('../src/models/redis'); -const logger = require('../src/utils/logger'); +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') async function debugRedisKeys() { try { - logger.info('🔄 Connecting to Redis...'); - await redis.connect(); - logger.success('✅ Connected to Redis'); - + logger.info('🔄 Connecting to Redis...') + await redis.connect() + logger.success('✅ Connected to Redis') + // 获取所有键 - const allKeys = await redis.client.keys('*'); - logger.info(`\n📊 Total keys in Redis: ${allKeys.length}\n`); - + const allKeys = await redis.client.keys('*') + logger.info(`\n📊 Total keys in Redis: ${allKeys.length}\n`) + // 按类型分组 const keysByType = { apiKeys: [], @@ -27,97 +27,100 @@ async function debugRedisKeys() { sessions: [], usage: [], other: [] - }; - + } + // 分类键 for (const key of allKeys) { if (key.startsWith('apikey:')) { - keysByType.apiKeys.push(key); + keysByType.apiKeys.push(key) } else if (key.startsWith('claude_account:')) { - keysByType.claudeAccounts.push(key); + keysByType.claudeAccounts.push(key) } else if (key.startsWith('gemini_account:')) { - keysByType.geminiAccounts.push(key); + keysByType.geminiAccounts.push(key) } else if (key.startsWith('admin:') || key.startsWith('admin_username:')) { - keysByType.admins.push(key); + keysByType.admins.push(key) } else if (key.startsWith('session:')) { - keysByType.sessions.push(key); - } else if (key.includes('usage') || key.includes('rate_limit') || key.includes('concurrency')) { - keysByType.usage.push(key); + keysByType.sessions.push(key) + } else if ( + key.includes('usage') || + key.includes('rate_limit') || + key.includes('concurrency') + ) { + keysByType.usage.push(key) } else { - keysByType.other.push(key); + keysByType.other.push(key) } } - + // 显示分类结果 - console.log('='.repeat(60)); - console.log('📂 Keys by Category:'); - console.log('='.repeat(60)); - console.log(`API Keys: ${keysByType.apiKeys.length}`); - console.log(`Claude Accounts: ${keysByType.claudeAccounts.length}`); - console.log(`Gemini Accounts: ${keysByType.geminiAccounts.length}`); - console.log(`Admins: ${keysByType.admins.length}`); - console.log(`Sessions: ${keysByType.sessions.length}`); - console.log(`Usage/Rate Limit: ${keysByType.usage.length}`); - console.log(`Other: ${keysByType.other.length}`); - console.log('='.repeat(60)); - + console.log('='.repeat(60)) + console.log('📂 Keys by Category:') + console.log('='.repeat(60)) + console.log(`API Keys: ${keysByType.apiKeys.length}`) + console.log(`Claude Accounts: ${keysByType.claudeAccounts.length}`) + console.log(`Gemini Accounts: ${keysByType.geminiAccounts.length}`) + console.log(`Admins: ${keysByType.admins.length}`) + console.log(`Sessions: ${keysByType.sessions.length}`) + console.log(`Usage/Rate Limit: ${keysByType.usage.length}`) + console.log(`Other: ${keysByType.other.length}`) + console.log('='.repeat(60)) + // 详细显示每个类别的键 if (keysByType.apiKeys.length > 0) { - console.log('\n🔑 API Keys:'); + console.log('\n🔑 API Keys:') for (const key of keysByType.apiKeys.slice(0, 5)) { - console.log(` - ${key}`); + console.log(` - ${key}`) } if (keysByType.apiKeys.length > 5) { - console.log(` ... and ${keysByType.apiKeys.length - 5} more`); + console.log(` ... and ${keysByType.apiKeys.length - 5} more`) } } - + if (keysByType.claudeAccounts.length > 0) { - console.log('\n🤖 Claude Accounts:'); + console.log('\n🤖 Claude Accounts:') for (const key of keysByType.claudeAccounts) { - console.log(` - ${key}`); + console.log(` - ${key}`) } } - + if (keysByType.geminiAccounts.length > 0) { - console.log('\n💎 Gemini Accounts:'); + console.log('\n💎 Gemini Accounts:') for (const key of keysByType.geminiAccounts) { - console.log(` - ${key}`); + console.log(` - ${key}`) } } - + if (keysByType.other.length > 0) { - console.log('\n❓ Other Keys:'); + console.log('\n❓ Other Keys:') for (const key of keysByType.other.slice(0, 10)) { - console.log(` - ${key}`); + console.log(` - ${key}`) } if (keysByType.other.length > 10) { - console.log(` ... and ${keysByType.other.length - 10} more`); + console.log(` ... and ${keysByType.other.length - 10} more`) } } - + // 检查数据类型 - console.log('\n' + '='.repeat(60)); - console.log('🔍 Checking Data Types:'); - console.log('='.repeat(60)); - + console.log(`\n${'='.repeat(60)}`) + console.log('🔍 Checking Data Types:') + console.log('='.repeat(60)) + // 随机检查几个键的类型 - const sampleKeys = allKeys.slice(0, Math.min(10, allKeys.length)); + const sampleKeys = allKeys.slice(0, Math.min(10, allKeys.length)) for (const key of sampleKeys) { - const type = await redis.client.type(key); - console.log(`${key} => ${type}`); + const type = await redis.client.type(key) + console.log(`${key} => ${type}`) } - } catch (error) { - logger.error('💥 Debug failed:', error); + logger.error('💥 Debug failed:', error) } finally { - await redis.disconnect(); - logger.info('👋 Disconnected from Redis'); + await redis.disconnect() + logger.info('👋 Disconnected from Redis') } } // 运行调试 -debugRedisKeys().catch(error => { - logger.error('💥 Unexpected error:', error); - process.exit(1); -}); \ No newline at end of file +debugRedisKeys().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/fix-inquirer.js b/scripts/fix-inquirer.js index 9afa8d5f..c21592bb 100644 --- a/scripts/fix-inquirer.js +++ b/scripts/fix-inquirer.js @@ -5,28 +5,25 @@ * 降级到支持 CommonJS 的版本 */ -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); +const { execSync } = require('child_process') -console.log('🔧 修复 inquirer ESM 兼容性问题...\n'); +console.log('🔧 修复 inquirer ESM 兼容性问题...\n') try { // 卸载当前版本 - console.log('📦 卸载当前 inquirer 版本...'); - execSync('npm uninstall inquirer', { stdio: 'inherit' }); - + console.log('📦 卸载当前 inquirer 版本...') + execSync('npm uninstall inquirer', { stdio: 'inherit' }) + // 安装兼容 CommonJS 的版本 (8.x 是最后支持 CommonJS 的主要版本) - console.log('\n📦 安装兼容版本 inquirer@8.2.6...'); - execSync('npm install inquirer@8.2.6', { stdio: 'inherit' }); - - console.log('\n✅ 修复完成!'); - console.log('\n现在可以正常使用 CLI 工具了:'); - console.log(' npm run cli admin'); - console.log(' npm run cli keys'); - console.log(' npm run cli status'); - + console.log('\n📦 安装兼容版本 inquirer@8.2.6...') + execSync('npm install inquirer@8.2.6', { stdio: 'inherit' }) + + console.log('\n✅ 修复完成!') + console.log('\n现在可以正常使用 CLI 工具了:') + console.log(' npm run cli admin') + console.log(' npm run cli keys') + console.log(' npm run cli status') } catch (error) { - console.error('❌ 修复失败:', error.message); - process.exit(1); -} \ No newline at end of file + console.error('❌ 修复失败:', error.message) + process.exit(1) +} diff --git a/scripts/fix-usage-stats.js b/scripts/fix-usage-stats.js index b725cc4a..af9cf984 100644 --- a/scripts/fix-usage-stats.js +++ b/scripts/fix-usage-stats.js @@ -2,226 +2,226 @@ /** * 数据迁移脚本:修复历史使用统计数据 - * + * * 功能: * 1. 统一 totalTokens 和 allTokens 字段 * 2. 确保 allTokens 包含所有类型的 tokens * 3. 修复历史数据的不一致性 - * + * * 使用方法: * node scripts/fix-usage-stats.js [--dry-run] */ -require('dotenv').config(); -const redis = require('../src/models/redis'); -const logger = require('../src/utils/logger'); +require('dotenv').config() +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') // 解析命令行参数 -const args = process.argv.slice(2); -const isDryRun = args.includes('--dry-run'); +const args = process.argv.slice(2) +const isDryRun = args.includes('--dry-run') async function fixUsageStats() { try { - logger.info('🔧 开始修复使用统计数据...'); + logger.info('🔧 开始修复使用统计数据...') if (isDryRun) { - logger.info('📝 DRY RUN 模式 - 不会实际修改数据'); + logger.info('📝 DRY RUN 模式 - 不会实际修改数据') } // 连接到 Redis - await redis.connect(); - logger.success('✅ 已连接到 Redis'); - - const client = redis.getClientSafe(); - + await redis.connect() + logger.success('✅ 已连接到 Redis') + + const client = redis.getClientSafe() + // 统计信息 - let stats = { + const stats = { totalKeys: 0, fixedTotalKeys: 0, fixedDailyKeys: 0, fixedMonthlyKeys: 0, fixedModelKeys: 0, errors: 0 - }; + } // 1. 修复 API Key 级别的总统计 - logger.info('\n📊 修复 API Key 总统计数据...'); - const apiKeyPattern = 'apikey:*'; - const apiKeys = await client.keys(apiKeyPattern); - stats.totalKeys = apiKeys.length; + logger.info('\n📊 修复 API Key 总统计数据...') + const apiKeyPattern = 'apikey:*' + const apiKeys = await client.keys(apiKeyPattern) + stats.totalKeys = apiKeys.length for (const apiKeyKey of apiKeys) { - const keyId = apiKeyKey.replace('apikey:', ''); - const usageKey = `usage:${keyId}`; - + const keyId = apiKeyKey.replace('apikey:', '') + const usageKey = `usage:${keyId}` + try { - const usageData = await client.hgetall(usageKey); + const usageData = await client.hgetall(usageKey) if (usageData && Object.keys(usageData).length > 0) { - const inputTokens = parseInt(usageData.totalInputTokens) || 0; - const outputTokens = parseInt(usageData.totalOutputTokens) || 0; - const cacheCreateTokens = parseInt(usageData.totalCacheCreateTokens) || 0; - const cacheReadTokens = parseInt(usageData.totalCacheReadTokens) || 0; - const currentAllTokens = parseInt(usageData.totalAllTokens) || 0; - + const inputTokens = parseInt(usageData.totalInputTokens) || 0 + const outputTokens = parseInt(usageData.totalOutputTokens) || 0 + const cacheCreateTokens = parseInt(usageData.totalCacheCreateTokens) || 0 + const cacheReadTokens = parseInt(usageData.totalCacheReadTokens) || 0 + const currentAllTokens = parseInt(usageData.totalAllTokens) || 0 + // 计算正确的 allTokens - const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; - + const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { - logger.info(` 修复 ${keyId}: ${currentAllTokens} -> ${correctAllTokens}`); - + logger.info(` 修复 ${keyId}: ${currentAllTokens} -> ${correctAllTokens}`) + if (!isDryRun) { - await client.hset(usageKey, 'totalAllTokens', correctAllTokens); + await client.hset(usageKey, 'totalAllTokens', correctAllTokens) } - stats.fixedTotalKeys++; + stats.fixedTotalKeys++ } } } catch (error) { - logger.error(` 错误处理 ${keyId}: ${error.message}`); - stats.errors++; + logger.error(` 错误处理 ${keyId}: ${error.message}`) + stats.errors++ } } // 2. 修复每日统计数据 - logger.info('\n📅 修复每日统计数据...'); - const dailyPattern = 'usage:daily:*'; - const dailyKeys = await client.keys(dailyPattern); + logger.info('\n📅 修复每日统计数据...') + const dailyPattern = 'usage:daily:*' + const dailyKeys = await client.keys(dailyPattern) for (const dailyKey of dailyKeys) { try { - const data = await client.hgetall(dailyKey); + const data = await client.hgetall(dailyKey) if (data && Object.keys(data).length > 0) { - 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 currentAllTokens = parseInt(data.allTokens) || 0; - - const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; - + 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 currentAllTokens = parseInt(data.allTokens) || 0 + + const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { if (!isDryRun) { - await client.hset(dailyKey, 'allTokens', correctAllTokens); + await client.hset(dailyKey, 'allTokens', correctAllTokens) } - stats.fixedDailyKeys++; + stats.fixedDailyKeys++ } } } catch (error) { - logger.error(` 错误处理 ${dailyKey}: ${error.message}`); - stats.errors++; + logger.error(` 错误处理 ${dailyKey}: ${error.message}`) + stats.errors++ } } // 3. 修复每月统计数据 - logger.info('\n📆 修复每月统计数据...'); - const monthlyPattern = 'usage:monthly:*'; - const monthlyKeys = await client.keys(monthlyPattern); + logger.info('\n📆 修复每月统计数据...') + const monthlyPattern = 'usage:monthly:*' + const monthlyKeys = await client.keys(monthlyPattern) for (const monthlyKey of monthlyKeys) { try { - const data = await client.hgetall(monthlyKey); + const data = await client.hgetall(monthlyKey) if (data && Object.keys(data).length > 0) { - 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 currentAllTokens = parseInt(data.allTokens) || 0; - - const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; - + 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 currentAllTokens = parseInt(data.allTokens) || 0 + + const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { if (!isDryRun) { - await client.hset(monthlyKey, 'allTokens', correctAllTokens); + await client.hset(monthlyKey, 'allTokens', correctAllTokens) } - stats.fixedMonthlyKeys++; + stats.fixedMonthlyKeys++ } } } catch (error) { - logger.error(` 错误处理 ${monthlyKey}: ${error.message}`); - stats.errors++; + logger.error(` 错误处理 ${monthlyKey}: ${error.message}`) + stats.errors++ } } // 4. 修复模型级别的统计数据 - logger.info('\n🤖 修复模型级别统计数据...'); + logger.info('\n🤖 修复模型级别统计数据...') const modelPatterns = [ 'usage:model:daily:*', 'usage:model:monthly:*', 'usage:*:model:daily:*', 'usage:*:model:monthly:*' - ]; + ] for (const pattern of modelPatterns) { - const modelKeys = await client.keys(pattern); - + const modelKeys = await client.keys(pattern) + for (const modelKey of modelKeys) { try { - const data = await client.hgetall(modelKey); + const data = await client.hgetall(modelKey) if (data && Object.keys(data).length > 0) { - 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 currentAllTokens = parseInt(data.allTokens) || 0; - - const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; - + 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 currentAllTokens = parseInt(data.allTokens) || 0 + + const correctAllTokens = + inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { if (!isDryRun) { - await client.hset(modelKey, 'allTokens', correctAllTokens); + await client.hset(modelKey, 'allTokens', correctAllTokens) } - stats.fixedModelKeys++; + stats.fixedModelKeys++ } } } catch (error) { - logger.error(` 错误处理 ${modelKey}: ${error.message}`); - stats.errors++; + logger.error(` 错误处理 ${modelKey}: ${error.message}`) + stats.errors++ } } } // 5. 验证修复结果 if (!isDryRun) { - logger.info('\n✅ 验证修复结果...'); - + logger.info('\n✅ 验证修复结果...') + // 随机抽样验证 - const sampleSize = Math.min(5, apiKeys.length); + const sampleSize = Math.min(5, apiKeys.length) for (let i = 0; i < sampleSize; i++) { - const randomIndex = Math.floor(Math.random() * apiKeys.length); - const keyId = apiKeys[randomIndex].replace('apikey:', ''); - const usage = await redis.getUsageStats(keyId); - - logger.info(` 样本 ${keyId}:`); - logger.info(` Total tokens: ${usage.total.tokens}`); - logger.info(` All tokens: ${usage.total.allTokens}`); - logger.info(` 一致性: ${usage.total.tokens === usage.total.allTokens ? '✅' : '❌'}`); + const randomIndex = Math.floor(Math.random() * apiKeys.length) + const keyId = apiKeys[randomIndex].replace('apikey:', '') + const usage = await redis.getUsageStats(keyId) + + logger.info(` 样本 ${keyId}:`) + logger.info(` Total tokens: ${usage.total.tokens}`) + logger.info(` All tokens: ${usage.total.allTokens}`) + logger.info(` 一致性: ${usage.total.tokens === usage.total.allTokens ? '✅' : '❌'}`) } } // 打印统计结果 - logger.info('\n📊 修复统计:'); - logger.info(` 总 API Keys: ${stats.totalKeys}`); - logger.info(` 修复的总统计: ${stats.fixedTotalKeys}`); - logger.info(` 修复的日统计: ${stats.fixedDailyKeys}`); - logger.info(` 修复的月统计: ${stats.fixedMonthlyKeys}`); - logger.info(` 修复的模型统计: ${stats.fixedModelKeys}`); - logger.info(` 错误数: ${stats.errors}`); + logger.info('\n📊 修复统计:') + logger.info(` 总 API Keys: ${stats.totalKeys}`) + logger.info(` 修复的总统计: ${stats.fixedTotalKeys}`) + logger.info(` 修复的日统计: ${stats.fixedDailyKeys}`) + logger.info(` 修复的月统计: ${stats.fixedMonthlyKeys}`) + logger.info(` 修复的模型统计: ${stats.fixedModelKeys}`) + logger.info(` 错误数: ${stats.errors}`) if (isDryRun) { - logger.info('\n💡 这是 DRY RUN - 没有实际修改数据'); - logger.info(' 运行不带 --dry-run 参数来实际执行修复'); + logger.info('\n💡 这是 DRY RUN - 没有实际修改数据') + logger.info(' 运行不带 --dry-run 参数来实际执行修复') } else { - logger.success('\n✅ 数据修复完成!'); + logger.success('\n✅ 数据修复完成!') } - } catch (error) { - logger.error('❌ 修复过程出错:', error); - process.exit(1); + logger.error('❌ 修复过程出错:', error) + process.exit(1) } finally { - await redis.disconnect(); + await redis.disconnect() } } // 执行修复 -fixUsageStats().catch(error => { - logger.error('❌ 未处理的错误:', error); - process.exit(1); -}); \ No newline at end of file +fixUsageStats().catch((error) => { + logger.error('❌ 未处理的错误:', error) + process.exit(1) +}) diff --git a/scripts/generate-test-data.js b/scripts/generate-test-data.js index 1cfc8ddb..a1b181bd 100755 --- a/scripts/generate-test-data.js +++ b/scripts/generate-test-data.js @@ -3,20 +3,20 @@ /** * 历史数据生成脚本 * 用于测试不同时间范围的Token统计功能 - * + * * 使用方法: * node scripts/generate-test-data.js [--clean] - * + * * 选项: * --clean: 清除所有测试数据 */ -const redis = require('../src/models/redis'); -const logger = require('../src/utils/logger'); +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') // 解析命令行参数 -const args = process.argv.slice(2); -const shouldClean = args.includes('--clean'); +const args = process.argv.slice(2) +const shouldClean = args.includes('--clean') // 模拟的模型列表 const models = [ @@ -24,41 +24,41 @@ const models = [ 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229' -]; +] // 生成指定日期的数据 async function generateDataForDate(apiKeyId, date, dayOffset) { - const client = redis.getClientSafe(); - const dateStr = date.toISOString().split('T')[0]; - const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; - + const client = redis.getClientSafe() + const dateStr = date.toISOString().split('T')[0] + const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + // 根据日期偏移量调整数据量(越近的日期数据越多) - const requestCount = Math.max(5, 20 - dayOffset * 2); // 5-20个请求 - - logger.info(`📊 Generating ${requestCount} requests for ${dateStr}`); - + const requestCount = Math.max(5, 20 - dayOffset * 2) // 5-20个请求 + + logger.info(`📊 Generating ${requestCount} requests for ${dateStr}`) + for (let i = 0; i < requestCount; i++) { // 随机选择模型 - const model = models[Math.floor(Math.random() * models.length)]; - + const model = models[Math.floor(Math.random() * models.length)] + // 生成随机Token数据 - const inputTokens = Math.floor(Math.random() * 2000) + 500; // 500-2500 - const outputTokens = Math.floor(Math.random() * 3000) + 1000; // 1000-4000 - const cacheCreateTokens = Math.random() > 0.7 ? Math.floor(Math.random() * 1000) : 0; // 30%概率有缓存创建 - const cacheReadTokens = Math.random() > 0.5 ? Math.floor(Math.random() * 500) : 0; // 50%概率有缓存读取 - - const coreTokens = inputTokens + outputTokens; - const allTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; - + const inputTokens = Math.floor(Math.random() * 2000) + 500 // 500-2500 + const outputTokens = Math.floor(Math.random() * 3000) + 1000 // 1000-4000 + const cacheCreateTokens = Math.random() > 0.7 ? Math.floor(Math.random() * 1000) : 0 // 30%概率有缓存创建 + const cacheReadTokens = Math.random() > 0.5 ? Math.floor(Math.random() * 500) : 0 // 50%概率有缓存读取 + + const coreTokens = inputTokens + outputTokens + const allTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + // 更新各种统计键 - const totalKey = `usage:${apiKeyId}`; - const dailyKey = `usage:daily:${apiKeyId}:${dateStr}`; - const monthlyKey = `usage:monthly:${apiKeyId}:${month}`; - const modelDailyKey = `usage:model:daily:${model}:${dateStr}`; - const modelMonthlyKey = `usage:model:monthly:${model}:${month}`; - const keyModelDailyKey = `usage:${apiKeyId}:model:daily:${model}:${dateStr}`; - const keyModelMonthlyKey = `usage:${apiKeyId}:model:monthly:${model}:${month}`; - + const totalKey = `usage:${apiKeyId}` + const dailyKey = `usage:daily:${apiKeyId}:${dateStr}` + const monthlyKey = `usage:monthly:${apiKeyId}:${month}` + const modelDailyKey = `usage:model:daily:${model}:${dateStr}` + const modelMonthlyKey = `usage:model:monthly:${model}:${month}` + const keyModelDailyKey = `usage:${apiKeyId}:model:daily:${model}:${dateStr}` + const keyModelMonthlyKey = `usage:${apiKeyId}:model:monthly:${model}:${month}` + await Promise.all([ // 总计数据 client.hincrby(totalKey, 'totalTokens', coreTokens), @@ -68,7 +68,7 @@ async function generateDataForDate(apiKeyId, date, dayOffset) { client.hincrby(totalKey, 'totalCacheReadTokens', cacheReadTokens), client.hincrby(totalKey, 'totalAllTokens', allTokens), client.hincrby(totalKey, 'totalRequests', 1), - + // 每日统计 client.hincrby(dailyKey, 'tokens', coreTokens), client.hincrby(dailyKey, 'inputTokens', inputTokens), @@ -77,7 +77,7 @@ async function generateDataForDate(apiKeyId, date, dayOffset) { client.hincrby(dailyKey, 'cacheReadTokens', cacheReadTokens), client.hincrby(dailyKey, 'allTokens', allTokens), client.hincrby(dailyKey, 'requests', 1), - + // 每月统计 client.hincrby(monthlyKey, 'tokens', coreTokens), client.hincrby(monthlyKey, 'inputTokens', inputTokens), @@ -86,7 +86,7 @@ async function generateDataForDate(apiKeyId, date, dayOffset) { client.hincrby(monthlyKey, 'cacheReadTokens', cacheReadTokens), client.hincrby(monthlyKey, 'allTokens', allTokens), client.hincrby(monthlyKey, 'requests', 1), - + // 模型统计 - 每日 client.hincrby(modelDailyKey, 'totalInputTokens', inputTokens), client.hincrby(modelDailyKey, 'totalOutputTokens', outputTokens), @@ -94,7 +94,7 @@ async function generateDataForDate(apiKeyId, date, dayOffset) { client.hincrby(modelDailyKey, 'totalCacheReadTokens', cacheReadTokens), client.hincrby(modelDailyKey, 'totalAllTokens', allTokens), client.hincrby(modelDailyKey, 'requests', 1), - + // 模型统计 - 每月 client.hincrby(modelMonthlyKey, 'totalInputTokens', inputTokens), client.hincrby(modelMonthlyKey, 'totalOutputTokens', outputTokens), @@ -102,7 +102,7 @@ async function generateDataForDate(apiKeyId, date, dayOffset) { client.hincrby(modelMonthlyKey, 'totalCacheReadTokens', cacheReadTokens), client.hincrby(modelMonthlyKey, 'totalAllTokens', allTokens), client.hincrby(modelMonthlyKey, 'requests', 1), - + // API Key级别的模型统计 - 每日 // 同时存储带total前缀和不带前缀的字段,保持兼容性 client.hincrby(keyModelDailyKey, 'inputTokens', inputTokens), @@ -116,7 +116,7 @@ async function generateDataForDate(apiKeyId, date, dayOffset) { client.hincrby(keyModelDailyKey, 'totalCacheReadTokens', cacheReadTokens), client.hincrby(keyModelDailyKey, 'totalAllTokens', allTokens), client.hincrby(keyModelDailyKey, 'requests', 1), - + // API Key级别的模型统计 - 每月 client.hincrby(keyModelMonthlyKey, 'inputTokens', inputTokens), client.hincrby(keyModelMonthlyKey, 'outputTokens', outputTokens), @@ -128,27 +128,27 @@ async function generateDataForDate(apiKeyId, date, dayOffset) { client.hincrby(keyModelMonthlyKey, 'totalCacheCreateTokens', cacheCreateTokens), client.hincrby(keyModelMonthlyKey, 'totalCacheReadTokens', cacheReadTokens), client.hincrby(keyModelMonthlyKey, 'totalAllTokens', allTokens), - client.hincrby(keyModelMonthlyKey, 'requests', 1), - ]); + client.hincrby(keyModelMonthlyKey, 'requests', 1) + ]) } } // 清除测试数据 async function cleanTestData() { - const client = redis.getClientSafe(); - const apiKeyService = require('../src/services/apiKeyService'); - - logger.info('🧹 Cleaning test data...'); - + const client = redis.getClientSafe() + const apiKeyService = require('../src/services/apiKeyService') + + logger.info('🧹 Cleaning test data...') + // 获取所有API Keys - const allKeys = await apiKeyService.getAllApiKeys(); - + const allKeys = await apiKeyService.getAllApiKeys() + // 找出所有测试 API Keys - const testKeys = allKeys.filter(key => key.name && key.name.startsWith('Test API Key')); - + const testKeys = allKeys.filter((key) => key.name && key.name.startsWith('Test API Key')) + for (const testKey of testKeys) { - const apiKeyId = testKey.id; - + const apiKeyId = testKey.id + // 获取所有相关的键 const patterns = [ `usage:${apiKeyId}`, @@ -156,32 +156,29 @@ async function cleanTestData() { `usage:monthly:${apiKeyId}:*`, `usage:${apiKeyId}:model:daily:*`, `usage:${apiKeyId}:model:monthly:*` - ]; - + ] + for (const pattern of patterns) { - const keys = await client.keys(pattern); + const keys = await client.keys(pattern) if (keys.length > 0) { - await client.del(...keys); - logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`); + await client.del(...keys) + logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`) } } - + // 删除 API Key 本身 - await apiKeyService.deleteApiKey(apiKeyId); - logger.info(`🗑️ Deleted test API Key: ${testKey.name} (${apiKeyId})`); + await apiKeyService.deleteApiKey(apiKeyId) + logger.info(`🗑️ Deleted test API Key: ${testKey.name} (${apiKeyId})`) } - + // 清除模型统计 - const modelPatterns = [ - 'usage:model:daily:*', - 'usage:model:monthly:*' - ]; - + const modelPatterns = ['usage:model:daily:*', 'usage:model:monthly:*'] + for (const pattern of modelPatterns) { - const keys = await client.keys(pattern); + const keys = await client.keys(pattern) if (keys.length > 0) { - await client.del(...keys); - logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`); + await client.del(...keys) + logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`) } } } @@ -189,17 +186,17 @@ async function cleanTestData() { // 主函数 async function main() { try { - await redis.connect(); - logger.success('✅ Connected to Redis'); - + await redis.connect() + logger.success('✅ Connected to Redis') + // 创建测试API Keys - const apiKeyService = require('../src/services/apiKeyService'); - let testApiKeys = []; - let createdKeys = []; - + const apiKeyService = require('../src/services/apiKeyService') + const testApiKeys = [] + const createdKeys = [] + // 总是创建新的测试 API Keys - logger.info('📝 Creating test API Keys...'); - + logger.info('📝 Creating test API Keys...') + for (let i = 1; i <= 3; i++) { const newKey = await apiKeyService.generateApiKey({ name: `Test API Key ${i}`, @@ -208,77 +205,76 @@ async function main() { concurrencyLimit: 10, rateLimitWindow: 60, rateLimitRequests: 100 - }); - - testApiKeys.push(newKey.id); - createdKeys.push(newKey); - logger.success(`✅ Created test API Key: ${newKey.name} (${newKey.id})`); - logger.info(` 🔑 API Key: ${newKey.apiKey}`); + }) + + testApiKeys.push(newKey.id) + createdKeys.push(newKey) + logger.success(`✅ Created test API Key: ${newKey.name} (${newKey.id})`) + logger.info(` 🔑 API Key: ${newKey.apiKey}`) } - + if (shouldClean) { - await cleanTestData(); - logger.success('✅ Test data cleaned successfully'); - return; + await cleanTestData() + logger.success('✅ Test data cleaned successfully') + return } - + // 生成历史数据 - const now = new Date(); - + const now = new Date() + for (const apiKeyId of testApiKeys) { - logger.info(`\n🔄 Generating data for API Key: ${apiKeyId}`); - + logger.info(`\n🔄 Generating data for API Key: ${apiKeyId}`) + // 生成过去30天的数据 for (let dayOffset = 0; dayOffset < 30; dayOffset++) { - const date = new Date(now); - date.setDate(date.getDate() - dayOffset); - - await generateDataForDate(apiKeyId, date, dayOffset); + const date = new Date(now) + date.setDate(date.getDate() - dayOffset) + + await generateDataForDate(apiKeyId, date, dayOffset) } - - logger.success(`✅ Generated 30 days of historical data for API Key: ${apiKeyId}`); + + logger.success(`✅ Generated 30 days of historical data for API Key: ${apiKeyId}`) } - + // 显示统计摘要 - logger.info('\n📊 Test Data Summary:'); - logger.info('='.repeat(60)); - + logger.info('\n📊 Test Data Summary:') + logger.info('='.repeat(60)) + for (const apiKeyId of testApiKeys) { - const totalKey = `usage:${apiKeyId}`; - const totalData = await redis.getClientSafe().hgetall(totalKey); - + const totalKey = `usage:${apiKeyId}` + const totalData = await redis.getClientSafe().hgetall(totalKey) + if (totalData && Object.keys(totalData).length > 0) { - logger.info(`\nAPI Key: ${apiKeyId}`); - logger.info(` Total Requests: ${totalData.totalRequests || 0}`); - logger.info(` Total Tokens (Core): ${totalData.totalTokens || 0}`); - logger.info(` Total Tokens (All): ${totalData.totalAllTokens || 0}`); - logger.info(` Input Tokens: ${totalData.totalInputTokens || 0}`); - logger.info(` Output Tokens: ${totalData.totalOutputTokens || 0}`); - logger.info(` Cache Create Tokens: ${totalData.totalCacheCreateTokens || 0}`); - logger.info(` Cache Read Tokens: ${totalData.totalCacheReadTokens || 0}`); + logger.info(`\nAPI Key: ${apiKeyId}`) + logger.info(` Total Requests: ${totalData.totalRequests || 0}`) + logger.info(` Total Tokens (Core): ${totalData.totalTokens || 0}`) + logger.info(` Total Tokens (All): ${totalData.totalAllTokens || 0}`) + logger.info(` Input Tokens: ${totalData.totalInputTokens || 0}`) + logger.info(` Output Tokens: ${totalData.totalOutputTokens || 0}`) + logger.info(` Cache Create Tokens: ${totalData.totalCacheCreateTokens || 0}`) + logger.info(` Cache Read Tokens: ${totalData.totalCacheReadTokens || 0}`) } } - - logger.info('\n' + '='.repeat(60)); - logger.success('\n✅ Test data generation completed!'); - logger.info('\n📋 Created API Keys:'); + + logger.info(`\n${'='.repeat(60)}`) + logger.success('\n✅ Test data generation completed!') + logger.info('\n📋 Created API Keys:') for (const key of createdKeys) { - logger.info(`- ${key.name}: ${key.apiKey}`); + logger.info(`- ${key.name}: ${key.apiKey}`) } - logger.info('\n💡 Tips:'); - logger.info('- Check the admin panel to see the different time ranges'); - logger.info('- Use --clean flag to remove all test data and API Keys'); - logger.info('- The script generates more recent data to simulate real usage patterns'); - + logger.info('\n💡 Tips:') + logger.info('- Check the admin panel to see the different time ranges') + logger.info('- Use --clean flag to remove all test data and API Keys') + logger.info('- The script generates more recent data to simulate real usage patterns') } catch (error) { - logger.error('❌ Error:', error); + logger.error('❌ Error:', error) } finally { - await redis.disconnect(); + await redis.disconnect() } } // 运行脚本 -main().catch(error => { - logger.error('💥 Unexpected error:', error); - process.exit(1); -}); \ No newline at end of file +main().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/manage-session-windows.js b/scripts/manage-session-windows.js index 4bfa834d..a8ed7a2d 100644 --- a/scripts/manage-session-windows.js +++ b/scripts/manage-session-windows.js @@ -5,548 +5,557 @@ * 用于调试、恢复和管理Claude账户的会话窗口 */ -const redis = require('../src/models/redis'); -const claudeAccountService = require('../src/services/claudeAccountService'); -const logger = require('../src/utils/logger'); -const readline = require('readline'); +const redis = require('../src/models/redis') +const claudeAccountService = require('../src/services/claudeAccountService') +const readline = require('readline') // 创建readline接口 const rl = readline.createInterface({ input: process.stdin, output: process.stdout -}); +}) // 辅助函数:询问用户输入 function askQuestion(question) { return new Promise((resolve) => { - rl.question(question, resolve); - }); + rl.question(question, resolve) + }) } // 辅助函数:解析时间输入 function parseTimeInput(input) { - const now = new Date(); - + const now = new Date() + // 如果是 HH:MM 格式 - const timeMatch = input.match(/^(\d{1,2}):(\d{2})$/); + const timeMatch = input.match(/^(\d{1,2}):(\d{2})$/) if (timeMatch) { - const hour = parseInt(timeMatch[1]); - const minute = parseInt(timeMatch[2]); - + const hour = parseInt(timeMatch[1]) + const minute = parseInt(timeMatch[2]) + if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) { - const time = new Date(now); - time.setHours(hour, minute, 0, 0); - return time; + const time = new Date(now) + time.setHours(hour, minute, 0, 0) + return time } } - + // 如果是相对时间(如 "2小时前") - const relativeMatch = input.match(/^(\d+)(小时|分钟)前$/); + const relativeMatch = input.match(/^(\d+)(小时|分钟)前$/) if (relativeMatch) { - const amount = parseInt(relativeMatch[1]); - const unit = relativeMatch[2]; - const time = new Date(now); - + const amount = parseInt(relativeMatch[1]) + const unit = relativeMatch[2] + const time = new Date(now) + if (unit === '小时') { - time.setHours(time.getHours() - amount); + time.setHours(time.getHours() - amount) } else if (unit === '分钟') { - time.setMinutes(time.getMinutes() - amount); + time.setMinutes(time.getMinutes() - amount) } - - return time; + + return time } - + // 如果是 ISO 格式或其他日期格式 - const parsedDate = new Date(input); + const parsedDate = new Date(input) if (!isNaN(parsedDate.getTime())) { - return parsedDate; + return parsedDate } - - return null; + + return null } // 辅助函数:显示可用的时间窗口选项 function showTimeWindowOptions() { - const now = new Date(); - console.log('\n⏰ 可用的5小时时间窗口:'); - + const now = new Date() + console.log('\n⏰ 可用的5小时时间窗口:') + for (let hour = 0; hour < 24; hour += 5) { - const start = hour; - const end = hour + 5; - const startStr = `${String(start).padStart(2, '0')}:00`; - const endStr = `${String(end).padStart(2, '0')}:00`; - - const currentHour = now.getHours(); - const isActive = currentHour >= start && currentHour < end; - const status = isActive ? ' 🟢 (当前活跃)' : ''; - - console.log(` ${start/5 + 1}. ${startStr} - ${endStr}${status}`); + const start = hour + const end = hour + 5 + const startStr = `${String(start).padStart(2, '0')}:00` + const endStr = `${String(end).padStart(2, '0')}:00` + + const currentHour = now.getHours() + const isActive = currentHour >= start && currentHour < end + const status = isActive ? ' 🟢 (当前活跃)' : '' + + console.log(` ${start / 5 + 1}. ${startStr} - ${endStr}${status}`) } - console.log(''); + console.log('') } const commands = { // 调试所有账户的会话窗口状态 async debug() { - console.log('🔍 开始调试会话窗口状态...\n'); - - const accounts = await redis.getAllClaudeAccounts(); - console.log(`📊 找到 ${accounts.length} 个Claude账户\n`); - + console.log('🔍 开始调试会话窗口状态...\n') + + const accounts = await redis.getAllClaudeAccounts() + console.log(`📊 找到 ${accounts.length} 个Claude账户\n`) + const stats = { total: accounts.length, hasWindow: 0, hasLastUsed: 0, canRecover: 0, expired: 0 - }; - + } + for (const account of accounts) { - console.log(`🏢 ${account.name} (${account.id})`); - console.log(` 状态: ${account.isActive === 'true' ? '✅ 活跃' : '❌ 禁用'}`); - + console.log(`🏢 ${account.name} (${account.id})`) + console.log(` 状态: ${account.isActive === 'true' ? '✅ 活跃' : '❌ 禁用'}`) + if (account.sessionWindowStart && account.sessionWindowEnd) { - stats.hasWindow++; - const windowStart = new Date(account.sessionWindowStart); - const windowEnd = new Date(account.sessionWindowEnd); - const now = new Date(); - const isActive = now < windowEnd; - - console.log(` 会话窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`); - console.log(` 窗口状态: ${isActive ? '✅ 活跃' : '❌ 已过期'}`); - + stats.hasWindow++ + const windowStart = new Date(account.sessionWindowStart) + const windowEnd = new Date(account.sessionWindowEnd) + const now = new Date() + const isActive = now < windowEnd + + console.log(` 会话窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + console.log(` 窗口状态: ${isActive ? '✅ 活跃' : '❌ 已过期'}`) + // 只有在窗口已过期时才显示可恢复窗口 if (!isActive && account.lastUsedAt) { - const lastUsed = new Date(account.lastUsedAt); - const recoveredWindowStart = claudeAccountService._calculateSessionWindowStart(lastUsed); - const recoveredWindowEnd = claudeAccountService._calculateSessionWindowEnd(recoveredWindowStart); - + const lastUsed = new Date(account.lastUsedAt) + const recoveredWindowStart = claudeAccountService._calculateSessionWindowStart(lastUsed) + const recoveredWindowEnd = + claudeAccountService._calculateSessionWindowEnd(recoveredWindowStart) + if (now < recoveredWindowEnd) { - stats.canRecover++; - console.log(` 可恢复窗口: ✅ ${recoveredWindowStart.toLocaleString()} - ${recoveredWindowEnd.toLocaleString()}`); + stats.canRecover++ + console.log( + ` 可恢复窗口: ✅ ${recoveredWindowStart.toLocaleString()} - ${recoveredWindowEnd.toLocaleString()}` + ) } else { - stats.expired++; - console.log(` 可恢复窗口: ❌ 已过期 (${recoveredWindowStart.toLocaleString()} - ${recoveredWindowEnd.toLocaleString()})`); + stats.expired++ + console.log( + ` 可恢复窗口: ❌ 已过期 (${recoveredWindowStart.toLocaleString()} - ${recoveredWindowEnd.toLocaleString()})` + ) } } } else { - console.log(` 会话窗口: ❌ 无`); - + console.log(' 会话窗口: ❌ 无') + // 没有会话窗口时,检查是否有可恢复的窗口 if (account.lastUsedAt) { - const lastUsed = new Date(account.lastUsedAt); - const now = new Date(); - const windowStart = claudeAccountService._calculateSessionWindowStart(lastUsed); - const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart); - + const lastUsed = new Date(account.lastUsedAt) + const now = new Date() + const windowStart = claudeAccountService._calculateSessionWindowStart(lastUsed) + const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart) + if (now < windowEnd) { - stats.canRecover++; - console.log(` 可恢复窗口: ✅ ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`); + stats.canRecover++ + console.log( + ` 可恢复窗口: ✅ ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}` + ) } else { - stats.expired++; - console.log(` 可恢复窗口: ❌ 已过期 (${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()})`); + stats.expired++ + console.log( + ` 可恢复窗口: ❌ 已过期 (${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()})` + ) } } } - + if (account.lastUsedAt) { - stats.hasLastUsed++; - const lastUsed = new Date(account.lastUsedAt); - const now = new Date(); - const minutesAgo = Math.round((now - lastUsed) / (1000 * 60)); - - console.log(` 最后使用: ${lastUsed.toLocaleString()} (${minutesAgo}分钟前)`); + stats.hasLastUsed++ + const lastUsed = new Date(account.lastUsedAt) + const now = new Date() + const minutesAgo = Math.round((now - lastUsed) / (1000 * 60)) + + console.log(` 最后使用: ${lastUsed.toLocaleString()} (${minutesAgo}分钟前)`) } else { - console.log(` 最后使用: ❌ 无记录`); + console.log(' 最后使用: ❌ 无记录') } - - console.log(''); + + console.log('') } - - console.log('📈 汇总统计:'); - console.log(` 总账户数: ${stats.total}`); - console.log(` 有会话窗口: ${stats.hasWindow}`); - console.log(` 有使用记录: ${stats.hasLastUsed}`); - console.log(` 可以恢复: ${stats.canRecover}`); - console.log(` 窗口已过期: ${stats.expired}`); + + console.log('📈 汇总统计:') + console.log(` 总账户数: ${stats.total}`) + console.log(` 有会话窗口: ${stats.hasWindow}`) + console.log(` 有使用记录: ${stats.hasLastUsed}`) + console.log(` 可以恢复: ${stats.canRecover}`) + console.log(` 窗口已过期: ${stats.expired}`) }, - + // 初始化会话窗口(默认行为) async init() { - console.log('🔄 初始化会话窗口...\n'); - const result = await claudeAccountService.initializeSessionWindows(); - - console.log('\n📊 初始化结果:'); - console.log(` 总账户数: ${result.total}`); - console.log(` 成功初始化: ${result.initialized}`); - console.log(` 已跳过(已有窗口): ${result.skipped}`); - console.log(` 窗口已过期: ${result.expired}`); - console.log(` 无使用数据: ${result.noData}`); - + console.log('🔄 初始化会话窗口...\n') + const result = await claudeAccountService.initializeSessionWindows() + + console.log('\n📊 初始化结果:') + console.log(` 总账户数: ${result.total}`) + console.log(` 成功初始化: ${result.initialized}`) + console.log(` 已跳过(已有窗口): ${result.skipped}`) + console.log(` 窗口已过期: ${result.expired}`) + console.log(` 无使用数据: ${result.noData}`) + if (result.error) { - console.log(` 错误: ${result.error}`); + console.log(` 错误: ${result.error}`) } }, - + // 强制重新计算所有会话窗口 async force() { - console.log('🔄 强制重新计算所有会话窗口...\n'); - const result = await claudeAccountService.initializeSessionWindows(true); - - console.log('\n📊 强制重算结果:'); - console.log(` 总账户数: ${result.total}`); - console.log(` 成功初始化: ${result.initialized}`); - console.log(` 窗口已过期: ${result.expired}`); - console.log(` 无使用数据: ${result.noData}`); - + console.log('🔄 强制重新计算所有会话窗口...\n') + const result = await claudeAccountService.initializeSessionWindows(true) + + console.log('\n📊 强制重算结果:') + console.log(` 总账户数: ${result.total}`) + console.log(` 成功初始化: ${result.initialized}`) + console.log(` 窗口已过期: ${result.expired}`) + console.log(` 无使用数据: ${result.noData}`) + if (result.error) { - console.log(` 错误: ${result.error}`); + console.log(` 错误: ${result.error}`) } }, - + // 清除所有会话窗口 async clear() { - console.log('🗑️ 清除所有会话窗口...\n'); - - const accounts = await redis.getAllClaudeAccounts(); - let clearedCount = 0; - + console.log('🗑️ 清除所有会话窗口...\n') + + const accounts = await redis.getAllClaudeAccounts() + let clearedCount = 0 + for (const account of accounts) { if (account.sessionWindowStart || account.sessionWindowEnd) { - delete account.sessionWindowStart; - delete account.sessionWindowEnd; - delete account.lastRequestTime; - - await redis.setClaudeAccount(account.id, account); - clearedCount++; - - console.log(`✅ 清除账户 ${account.name} (${account.id}) 的会话窗口`); + delete account.sessionWindowStart + delete account.sessionWindowEnd + delete account.lastRequestTime + + await redis.setClaudeAccount(account.id, account) + clearedCount++ + + console.log(`✅ 清除账户 ${account.name} (${account.id}) 的会话窗口`) } } - - console.log(`\n📊 清除完成: 共清除 ${clearedCount} 个账户的会话窗口`); + + console.log(`\n📊 清除完成: 共清除 ${clearedCount} 个账户的会话窗口`) }, - + // 创建测试会话窗口(将lastUsedAt设置为当前时间) async test() { - console.log('🧪 创建测试会话窗口...\n'); - - const accounts = await redis.getAllClaudeAccounts(); + console.log('🧪 创建测试会话窗口...\n') + + const accounts = await redis.getAllClaudeAccounts() if (accounts.length === 0) { - console.log('❌ 没有找到Claude账户'); - return; + console.log('❌ 没有找到Claude账户') + return } - - const now = new Date(); - let updatedCount = 0; - + + const now = new Date() + let updatedCount = 0 + for (const account of accounts) { if (account.isActive === 'true') { // 设置为当前时间(模拟刚刚使用) - account.lastUsedAt = now.toISOString(); - + account.lastUsedAt = now.toISOString() + // 计算新的会话窗口 - const windowStart = claudeAccountService._calculateSessionWindowStart(now); - const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart); - - account.sessionWindowStart = windowStart.toISOString(); - account.sessionWindowEnd = windowEnd.toISOString(); - account.lastRequestTime = now.toISOString(); - - await redis.setClaudeAccount(account.id, account); - updatedCount++; - - console.log(`✅ 为账户 ${account.name} 创建测试会话窗口:`); - console.log(` 窗口时间: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`); - console.log(` 最后使用: ${now.toLocaleString()}\n`); + const windowStart = claudeAccountService._calculateSessionWindowStart(now) + const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart) + + account.sessionWindowStart = windowStart.toISOString() + account.sessionWindowEnd = windowEnd.toISOString() + account.lastRequestTime = now.toISOString() + + await redis.setClaudeAccount(account.id, account) + updatedCount++ + + console.log(`✅ 为账户 ${account.name} 创建测试会话窗口:`) + console.log(` 窗口时间: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + console.log(` 最后使用: ${now.toLocaleString()}\n`) } } - - console.log(`📊 测试完成: 为 ${updatedCount} 个活跃账户创建了测试会话窗口`); + + console.log(`📊 测试完成: 为 ${updatedCount} 个活跃账户创建了测试会话窗口`) }, - + // 手动设置账户的会话窗口 async set() { - console.log('🔧 手动设置会话窗口...\n'); - + console.log('🔧 手动设置会话窗口...\n') + // 获取所有账户 - const accounts = await redis.getAllClaudeAccounts(); + const accounts = await redis.getAllClaudeAccounts() if (accounts.length === 0) { - console.log('❌ 没有找到Claude账户'); - return; + console.log('❌ 没有找到Claude账户') + return } - + // 显示账户列表 - console.log('📋 可用的Claude账户:'); + console.log('📋 可用的Claude账户:') accounts.forEach((account, index) => { - const status = account.isActive === 'true' ? '✅' : '❌'; - const hasWindow = account.sessionWindowStart ? '🕐' : '➖'; - console.log(` ${index + 1}. ${status} ${hasWindow} ${account.name} (${account.id})`); - }); - + const status = account.isActive === 'true' ? '✅' : '❌' + const hasWindow = account.sessionWindowStart ? '🕐' : '➖' + console.log(` ${index + 1}. ${status} ${hasWindow} ${account.name} (${account.id})`) + }) + // 让用户选择账户 - const accountIndex = await askQuestion('\n请选择账户 (输入编号): '); - const selectedIndex = parseInt(accountIndex) - 1; - + const accountIndex = await askQuestion('\n请选择账户 (输入编号): ') + const selectedIndex = parseInt(accountIndex) - 1 + if (selectedIndex < 0 || selectedIndex >= accounts.length) { - console.log('❌ 无效的账户编号'); - return; + console.log('❌ 无效的账户编号') + return } - - const selectedAccount = accounts[selectedIndex]; - console.log(`\n🎯 已选择账户: ${selectedAccount.name}`); - + + const selectedAccount = accounts[selectedIndex] + console.log(`\n🎯 已选择账户: ${selectedAccount.name}`) + // 显示当前会话窗口状态 if (selectedAccount.sessionWindowStart && selectedAccount.sessionWindowEnd) { - const windowStart = new Date(selectedAccount.sessionWindowStart); - const windowEnd = new Date(selectedAccount.sessionWindowEnd); - const now = new Date(); - const isActive = now >= windowStart && now < windowEnd; - - console.log(`📊 当前会话窗口:`); - console.log(` 时间: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`); - console.log(` 状态: ${isActive ? '✅ 活跃' : '❌ 已过期'}`); + const windowStart = new Date(selectedAccount.sessionWindowStart) + const windowEnd = new Date(selectedAccount.sessionWindowEnd) + const now = new Date() + const isActive = now >= windowStart && now < windowEnd + + console.log('📊 当前会话窗口:') + console.log(` 时间: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + console.log(` 状态: ${isActive ? '✅ 活跃' : '❌ 已过期'}`) } else { - console.log(`📊 当前会话窗口: ❌ 无`); + console.log('📊 当前会话窗口: ❌ 无') } - + // 显示设置选项 - console.log('\n🛠️ 设置选项:'); - console.log(' 1. 使用预设时间窗口'); - console.log(' 2. 自定义最后使用时间'); - console.log(' 3. 直接设置窗口时间'); - console.log(' 4. 清除会话窗口'); - - const option = await askQuestion('\n请选择设置方式 (1-4): '); - + console.log('\n🛠️ 设置选项:') + console.log(' 1. 使用预设时间窗口') + console.log(' 2. 自定义最后使用时间') + console.log(' 3. 直接设置窗口时间') + console.log(' 4. 清除会话窗口') + + const option = await askQuestion('\n请选择设置方式 (1-4): ') + switch (option) { case '1': - await setPresetWindow(selectedAccount); - break; + await setPresetWindow(selectedAccount) + break case '2': - await setCustomLastUsed(selectedAccount); - break; + await setCustomLastUsed(selectedAccount) + break case '3': - await setDirectWindow(selectedAccount); - break; + await setDirectWindow(selectedAccount) + break case '4': - await clearAccountWindow(selectedAccount); - break; + await clearAccountWindow(selectedAccount) + break default: - console.log('❌ 无效的选项'); - return; + console.log('❌ 无效的选项') + return } }, - + // 显示帮助信息 help() { - console.log('🔧 会话窗口管理脚本\n'); - console.log('用法: node scripts/manage-session-windows.js \n'); - console.log('命令:'); - console.log(' debug - 调试所有账户的会话窗口状态'); - console.log(' init - 初始化会话窗口(跳过已有窗口的账户)'); - console.log(' force - 强制重新计算所有会话窗口'); - console.log(' test - 创建测试会话窗口(设置当前时间为使用时间)'); - console.log(' set - 手动设置特定账户的会话窗口 🆕'); - console.log(' clear - 清除所有会话窗口'); - console.log(' help - 显示此帮助信息\n'); - console.log('示例:'); - console.log(' node scripts/manage-session-windows.js debug'); - console.log(' node scripts/manage-session-windows.js set'); - console.log(' node scripts/manage-session-windows.js test'); - console.log(' node scripts/manage-session-windows.js force'); + console.log('🔧 会话窗口管理脚本\n') + console.log('用法: node scripts/manage-session-windows.js \n') + console.log('命令:') + console.log(' debug - 调试所有账户的会话窗口状态') + console.log(' init - 初始化会话窗口(跳过已有窗口的账户)') + console.log(' force - 强制重新计算所有会话窗口') + console.log(' test - 创建测试会话窗口(设置当前时间为使用时间)') + console.log(' set - 手动设置特定账户的会话窗口 🆕') + console.log(' clear - 清除所有会话窗口') + console.log(' help - 显示此帮助信息\n') + console.log('示例:') + console.log(' node scripts/manage-session-windows.js debug') + console.log(' node scripts/manage-session-windows.js set') + console.log(' node scripts/manage-session-windows.js test') + console.log(' node scripts/manage-session-windows.js force') } -}; +} // 设置函数实现 // 使用预设时间窗口 async function setPresetWindow(account) { - showTimeWindowOptions(); - - const windowChoice = await askQuestion('请选择时间窗口 (1-5): '); - const windowIndex = parseInt(windowChoice) - 1; - + showTimeWindowOptions() + + const windowChoice = await askQuestion('请选择时间窗口 (1-5): ') + const windowIndex = parseInt(windowChoice) - 1 + if (windowIndex < 0 || windowIndex >= 5) { - console.log('❌ 无效的窗口选择'); - return; + console.log('❌ 无效的窗口选择') + return } - - const now = new Date(); - const startHour = windowIndex * 5; - + + const now = new Date() + const startHour = windowIndex * 5 + // 创建窗口开始时间 - const windowStart = new Date(now); - windowStart.setHours(startHour, 0, 0, 0); - + const windowStart = new Date(now) + windowStart.setHours(startHour, 0, 0, 0) + // 创建窗口结束时间 - const windowEnd = new Date(windowStart); - windowEnd.setHours(windowEnd.getHours() + 5); - + const windowEnd = new Date(windowStart) + windowEnd.setHours(windowEnd.getHours() + 5) + // 如果选择的窗口已经过期,则设置为明天的同一时间段 if (windowEnd <= now) { - windowStart.setDate(windowStart.getDate() + 1); - windowEnd.setDate(windowEnd.getDate() + 1); + windowStart.setDate(windowStart.getDate() + 1) + windowEnd.setDate(windowEnd.getDate() + 1) } - + // 询问是否要设置为当前时间作为最后使用时间 - const setLastUsed = await askQuestion('是否设置当前时间为最后使用时间? (y/N): '); - + const setLastUsed = await askQuestion('是否设置当前时间为最后使用时间? (y/N): ') + // 更新账户数据 - account.sessionWindowStart = windowStart.toISOString(); - account.sessionWindowEnd = windowEnd.toISOString(); - account.lastRequestTime = now.toISOString(); - + account.sessionWindowStart = windowStart.toISOString() + account.sessionWindowEnd = windowEnd.toISOString() + account.lastRequestTime = now.toISOString() + if (setLastUsed.toLowerCase() === 'y' || setLastUsed.toLowerCase() === 'yes') { - account.lastUsedAt = now.toISOString(); + account.lastUsedAt = now.toISOString() } - - await redis.setClaudeAccount(account.id, account); - - console.log(`\n✅ 已设置会话窗口:`); - console.log(` 账户: ${account.name}`); - console.log(` 窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`); - console.log(` 状态: ${now >= windowStart && now < windowEnd ? '✅ 活跃' : '⏰ 未来窗口'}`); + + await redis.setClaudeAccount(account.id, account) + + console.log('\n✅ 已设置会话窗口:') + console.log(` 账户: ${account.name}`) + console.log(` 窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + console.log(` 状态: ${now >= windowStart && now < windowEnd ? '✅ 活跃' : '⏰ 未来窗口'}`) } // 自定义最后使用时间 async function setCustomLastUsed(account) { - console.log('\n📝 设置最后使用时间:'); - console.log('支持的时间格式:'); - console.log(' - HH:MM (如: 14:30)'); - console.log(' - 相对时间 (如: 2小时前, 30分钟前)'); - console.log(' - ISO格式 (如: 2025-07-28T14:30:00)'); - - const timeInput = await askQuestion('\n请输入最后使用时间: '); - const lastUsedTime = parseTimeInput(timeInput); - + console.log('\n📝 设置最后使用时间:') + console.log('支持的时间格式:') + console.log(' - HH:MM (如: 14:30)') + console.log(' - 相对时间 (如: 2小时前, 30分钟前)') + console.log(' - ISO格式 (如: 2025-07-28T14:30:00)') + + const timeInput = await askQuestion('\n请输入最后使用时间: ') + const lastUsedTime = parseTimeInput(timeInput) + if (!lastUsedTime) { - console.log('❌ 无效的时间格式'); - return; + console.log('❌ 无效的时间格式') + return } - + // 基于最后使用时间计算会话窗口 - const windowStart = claudeAccountService._calculateSessionWindowStart(lastUsedTime); - const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart); - + const windowStart = claudeAccountService._calculateSessionWindowStart(lastUsedTime) + const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart) + // 更新账户数据 - account.lastUsedAt = lastUsedTime.toISOString(); - account.sessionWindowStart = windowStart.toISOString(); - account.sessionWindowEnd = windowEnd.toISOString(); - account.lastRequestTime = lastUsedTime.toISOString(); - - await redis.setClaudeAccount(account.id, account); - - console.log(`\n✅ 已设置会话窗口:`); - console.log(` 账户: ${account.name}`); - console.log(` 最后使用: ${lastUsedTime.toLocaleString()}`); - console.log(` 窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`); - - const now = new Date(); - console.log(` 状态: ${now >= windowStart && now < windowEnd ? '✅ 活跃' : '❌ 已过期'}`); + account.lastUsedAt = lastUsedTime.toISOString() + account.sessionWindowStart = windowStart.toISOString() + account.sessionWindowEnd = windowEnd.toISOString() + account.lastRequestTime = lastUsedTime.toISOString() + + await redis.setClaudeAccount(account.id, account) + + console.log('\n✅ 已设置会话窗口:') + console.log(` 账户: ${account.name}`) + console.log(` 最后使用: ${lastUsedTime.toLocaleString()}`) + console.log(` 窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + + const now = new Date() + console.log(` 状态: ${now >= windowStart && now < windowEnd ? '✅ 活跃' : '❌ 已过期'}`) } // 直接设置窗口时间 async function setDirectWindow(account) { - console.log('\n⏰ 直接设置窗口时间:'); - - const startInput = await askQuestion('请输入窗口开始时间 (HH:MM): '); - const startTime = parseTimeInput(startInput); - + console.log('\n⏰ 直接设置窗口时间:') + + const startInput = await askQuestion('请输入窗口开始时间 (HH:MM): ') + const startTime = parseTimeInput(startInput) + if (!startTime) { - console.log('❌ 无效的开始时间格式'); - return; + console.log('❌ 无效的开始时间格式') + return } - + // 自动计算结束时间(开始时间+5小时) - const endTime = new Date(startTime); - endTime.setHours(endTime.getHours() + 5); - + const endTime = new Date(startTime) + endTime.setHours(endTime.getHours() + 5) + // 如果跨天,询问是否确认 if (endTime.getDate() !== startTime.getDate()) { - const confirm = await askQuestion(`窗口将跨天到次日 ${endTime.getHours()}:00,确认吗? (y/N): `); + const confirm = await askQuestion(`窗口将跨天到次日 ${endTime.getHours()}:00,确认吗? (y/N): `) if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') { - console.log('❌ 已取消设置'); - return; + console.log('❌ 已取消设置') + return } } - - const now = new Date(); - + + const now = new Date() + // 更新账户数据 - account.sessionWindowStart = startTime.toISOString(); - account.sessionWindowEnd = endTime.toISOString(); - account.lastRequestTime = now.toISOString(); - + account.sessionWindowStart = startTime.toISOString() + account.sessionWindowEnd = endTime.toISOString() + account.lastRequestTime = now.toISOString() + // 询问是否更新最后使用时间 - const updateLastUsed = await askQuestion('是否将最后使用时间设置为窗口开始时间? (y/N): '); + const updateLastUsed = await askQuestion('是否将最后使用时间设置为窗口开始时间? (y/N): ') if (updateLastUsed.toLowerCase() === 'y' || updateLastUsed.toLowerCase() === 'yes') { - account.lastUsedAt = startTime.toISOString(); + account.lastUsedAt = startTime.toISOString() } - - await redis.setClaudeAccount(account.id, account); - - console.log(`\n✅ 已设置会话窗口:`); - console.log(` 账户: ${account.name}`); - console.log(` 窗口: ${startTime.toLocaleString()} - ${endTime.toLocaleString()}`); - console.log(` 状态: ${now >= startTime && now < endTime ? '✅ 活跃' : (now < startTime ? '⏰ 未来窗口' : '❌ 已过期')}`); + + await redis.setClaudeAccount(account.id, account) + + console.log('\n✅ 已设置会话窗口:') + console.log(` 账户: ${account.name}`) + console.log(` 窗口: ${startTime.toLocaleString()} - ${endTime.toLocaleString()}`) + console.log( + ` 状态: ${now >= startTime && now < endTime ? '✅ 活跃' : now < startTime ? '⏰ 未来窗口' : '❌ 已过期'}` + ) } // 清除账户会话窗口 async function clearAccountWindow(account) { - const confirm = await askQuestion(`确认清除账户 "${account.name}" 的会话窗口吗? (y/N): `); - + const confirm = await askQuestion(`确认清除账户 "${account.name}" 的会话窗口吗? (y/N): `) + if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') { - console.log('❌ 已取消操作'); - return; + console.log('❌ 已取消操作') + return } - + // 清除会话窗口相关数据 - delete account.sessionWindowStart; - delete account.sessionWindowEnd; - delete account.lastRequestTime; - - await redis.setClaudeAccount(account.id, account); - - console.log(`\n✅ 已清除账户 "${account.name}" 的会话窗口`); + delete account.sessionWindowStart + delete account.sessionWindowEnd + delete account.lastRequestTime + + await redis.setClaudeAccount(account.id, account) + + console.log(`\n✅ 已清除账户 "${account.name}" 的会话窗口`) } async function main() { - const command = process.argv[2] || 'help'; - + const command = process.argv[2] || 'help' + if (!commands[command]) { - console.error(`❌ 未知命令: ${command}`); - commands.help(); - process.exit(1); + console.error(`❌ 未知命令: ${command}`) + commands.help() + process.exit(1) } - + if (command === 'help') { - commands.help(); - return; + commands.help() + return } - + try { // 连接Redis - await redis.connect(); - + await redis.connect() + // 执行命令 - await commands[command](); - + await commands[command]() } catch (error) { - console.error('❌ 执行失败:', error); - process.exit(1); + console.error('❌ 执行失败:', error) + process.exit(1) } finally { - await redis.disconnect(); - rl.close(); + await redis.disconnect() + rl.close() } } // 如果直接运行此脚本 if (require.main === module) { main().then(() => { - console.log('\n🎉 操作完成'); - process.exit(0); - }); + console.log('\n🎉 操作完成') + process.exit(0) + }) } -module.exports = { commands }; \ No newline at end of file +module.exports = { commands } diff --git a/scripts/manage.js b/scripts/manage.js index 5c4b9993..6e3f7937 100644 --- a/scripts/manage.js +++ b/scripts/manage.js @@ -1,235 +1,232 @@ #!/usr/bin/env node -const { spawn, exec } = require('child_process'); -const fs = require('fs'); -const path = require('path'); -const process = require('process'); +const { spawn, exec } = require('child_process') +const fs = require('fs') +const path = require('path') +const process = require('process') -const PID_FILE = path.join(__dirname, '..', 'claude-relay-service.pid'); -const LOG_FILE = path.join(__dirname, '..', 'logs', 'service.log'); -const ERROR_LOG_FILE = path.join(__dirname, '..', 'logs', 'service-error.log'); -const APP_FILE = path.join(__dirname, '..', 'src', 'app.js'); +const PID_FILE = path.join(__dirname, '..', 'claude-relay-service.pid') +const LOG_FILE = path.join(__dirname, '..', 'logs', 'service.log') +const ERROR_LOG_FILE = path.join(__dirname, '..', 'logs', 'service-error.log') +const APP_FILE = path.join(__dirname, '..', 'src', 'app.js') class ServiceManager { - constructor() { - this.ensureLogDir(); + constructor() { + this.ensureLogDir() + } + + ensureLogDir() { + const logDir = path.dirname(LOG_FILE) + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }) + } + } + + getPid() { + try { + if (fs.existsSync(PID_FILE)) { + const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim()) + return pid + } + } catch (error) { + console.error('读取PID文件失败:', error.message) + } + return null + } + + isProcessRunning(pid) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return false + } + } + + writePid(pid) { + try { + fs.writeFileSync(PID_FILE, pid.toString()) + console.log(`✅ PID ${pid} 已保存到 ${PID_FILE}`) + } catch (error) { + console.error('写入PID文件失败:', error.message) + } + } + + removePidFile() { + try { + if (fs.existsSync(PID_FILE)) { + fs.unlinkSync(PID_FILE) + console.log('🗑️ 已清理PID文件') + } + } catch (error) { + console.error('清理PID文件失败:', error.message) + } + } + + getStatus() { + const pid = this.getPid() + if (pid && this.isProcessRunning(pid)) { + return { running: true, pid } + } + return { running: false, pid: null } + } + + start(daemon = false) { + const status = this.getStatus() + if (status.running) { + console.log(`⚠️ 服务已在运行中 (PID: ${status.pid})`) + return false } - ensureLogDir() { - const logDir = path.dirname(LOG_FILE); - if (!fs.existsSync(logDir)) { - fs.mkdirSync(logDir, { recursive: true }); - } - } + console.log('🚀 启动 Claude Relay Service...') - getPid() { - try { - if (fs.existsSync(PID_FILE)) { - const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim()); - return pid; - } - } catch (error) { - console.error('读取PID文件失败:', error.message); - } - return null; - } + if (daemon) { + // 后台运行模式 - 使用nohup实现真正的后台运行 + const { exec: execChild } = require('child_process') - isProcessRunning(pid) { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return false; - } - } + const command = `nohup node "${APP_FILE}" > "${LOG_FILE}" 2> "${ERROR_LOG_FILE}" & echo $!` - writePid(pid) { - try { - fs.writeFileSync(PID_FILE, pid.toString()); - console.log(`✅ PID ${pid} 已保存到 ${PID_FILE}`); - } catch (error) { - console.error('写入PID文件失败:', error.message); - } - } - - removePidFile() { - try { - if (fs.existsSync(PID_FILE)) { - fs.unlinkSync(PID_FILE); - console.log('🗑️ 已清理PID文件'); - } - } catch (error) { - console.error('清理PID文件失败:', error.message); - } - } - - getStatus() { - const pid = this.getPid(); - if (pid && this.isProcessRunning(pid)) { - return { running: true, pid }; - } - return { running: false, pid: null }; - } - - start(daemon = false) { - const status = this.getStatus(); - if (status.running) { - console.log(`⚠️ 服务已在运行中 (PID: ${status.pid})`); - return false; + execChild(command, (error, stdout) => { + if (error) { + console.error('❌ 后台启动失败:', error.message) + return } - console.log('🚀 启动 Claude Relay Service...'); - - if (daemon) { - // 后台运行模式 - 使用nohup实现真正的后台运行 - const { exec } = require('child_process'); - - const command = `nohup node "${APP_FILE}" > "${LOG_FILE}" 2> "${ERROR_LOG_FILE}" & echo $!`; - - exec(command, (error, stdout, stderr) => { - if (error) { - console.error('❌ 后台启动失败:', error.message); - return; - } - - const pid = parseInt(stdout.trim()); - if (pid && !isNaN(pid)) { - this.writePid(pid); - console.log(`🔄 服务已在后台启动 (PID: ${pid})`); - console.log(`📝 日志文件: ${LOG_FILE}`); - console.log(`❌ 错误日志: ${ERROR_LOG_FILE}`); - console.log('✅ 终端现在可以安全关闭'); - } else { - console.error('❌ 无法获取进程ID'); - } - }); - - // 给exec一点时间执行 - setTimeout(() => { - process.exit(0); - }, 1000); - + const pid = parseInt(stdout.trim()) + if (pid && !isNaN(pid)) { + this.writePid(pid) + console.log(`🔄 服务已在后台启动 (PID: ${pid})`) + console.log(`📝 日志文件: ${LOG_FILE}`) + console.log(`❌ 错误日志: ${ERROR_LOG_FILE}`) + console.log('✅ 终端现在可以安全关闭') } else { - // 前台运行模式 - const child = spawn('node', [APP_FILE], { - stdio: 'inherit' - }); + console.error('❌ 无法获取进程ID') + } + }) - console.log(`🔄 服务已启动 (PID: ${child.pid})`); - - this.writePid(child.pid); + // 给exec一点时间执行 + setTimeout(() => { + process.exit(0) + }, 1000) + } else { + // 前台运行模式 + const child = spawn('node', [APP_FILE], { + stdio: 'inherit' + }) - // 监听进程退出 - child.on('exit', (code, signal) => { - this.removePidFile(); - if (code !== 0) { - console.log(`💥 进程退出 (代码: ${code}, 信号: ${signal})`); - } - }); + console.log(`🔄 服务已启动 (PID: ${child.pid})`) - child.on('error', (error) => { - console.error('❌ 启动失败:', error.message); - this.removePidFile(); - }); + this.writePid(child.pid) + + // 监听进程退出 + child.on('exit', (code, signal) => { + this.removePidFile() + if (code !== 0) { + console.log(`💥 进程退出 (代码: ${code}, 信号: ${signal})`) + } + }) + + child.on('error', (error) => { + console.error('❌ 启动失败:', error.message) + this.removePidFile() + }) + } + + return true + } + + stop() { + const status = this.getStatus() + if (!status.running) { + console.log('⚠️ 服务未在运行') + this.removePidFile() // 清理可能存在的过期PID文件 + return false + } + + console.log(`🛑 停止服务 (PID: ${status.pid})...`) + + try { + // 优雅关闭:先发送SIGTERM + process.kill(status.pid, 'SIGTERM') + + // 等待进程退出 + let attempts = 0 + const maxAttempts = 30 // 30秒超时 + + const checkExit = setInterval(() => { + attempts++ + if (!this.isProcessRunning(status.pid)) { + clearInterval(checkExit) + console.log('✅ 服务已停止') + this.removePidFile() + return } - return true; - } - - stop() { - const status = this.getStatus(); - if (!status.running) { - console.log('⚠️ 服务未在运行'); - this.removePidFile(); // 清理可能存在的过期PID文件 - return false; + if (attempts >= maxAttempts) { + clearInterval(checkExit) + console.log('⚠️ 优雅关闭超时,强制终止进程...') + try { + process.kill(status.pid, 'SIGKILL') + console.log('✅ 服务已强制停止') + } catch (error) { + console.error('❌ 强制停止失败:', error.message) + } + this.removePidFile() } + }, 1000) + } catch (error) { + console.error('❌ 停止服务失败:', error.message) + this.removePidFile() + return false + } - console.log(`🛑 停止服务 (PID: ${status.pid})...`); + return true + } - try { - // 优雅关闭:先发送SIGTERM - process.kill(status.pid, 'SIGTERM'); + restart(daemon = false) { + console.log('🔄 重启服务...') - // 等待进程退出 - let attempts = 0; - const maxAttempts = 30; // 30秒超时 + // 等待停止完成 + setTimeout(() => { + this.start(daemon) + }, 2000) - const checkExit = setInterval(() => { - attempts++; - if (!this.isProcessRunning(status.pid)) { - clearInterval(checkExit); - console.log('✅ 服务已停止'); - this.removePidFile(); - return; - } + return true + } - if (attempts >= maxAttempts) { - clearInterval(checkExit); - console.log('⚠️ 优雅关闭超时,强制终止进程...'); - try { - process.kill(status.pid, 'SIGKILL'); - console.log('✅ 服务已强制停止'); - } catch (error) { - console.error('❌ 强制停止失败:', error.message); - } - this.removePidFile(); - } - }, 1000); + status() { + const status = this.getStatus() + if (status.running) { + console.log(`✅ 服务正在运行 (PID: ${status.pid})`) - } catch (error) { - console.error('❌ 停止服务失败:', error.message); - this.removePidFile(); - return false; + // 显示进程信息 + exec(`ps -p ${status.pid} -o pid,ppid,pcpu,pmem,etime,cmd --no-headers`, (error, stdout) => { + if (!error && stdout.trim()) { + console.log('\n📊 进程信息:') + console.log('PID\tPPID\tCPU%\tMEM%\tTIME\t\tCOMMAND') + console.log(stdout.trim()) } - - return true; + }) + } else { + console.log('❌ 服务未运行') } + return status.running + } - restart(daemon = false) { - console.log('🔄 重启服务...'); - const stopResult = this.stop(); - - // 等待停止完成 - setTimeout(() => { - this.start(daemon); - }, 2000); - - return true; - } + logs(lines = 50) { + console.log(`📖 最近 ${lines} 行日志:\n`) - status() { - const status = this.getStatus(); - if (status.running) { - console.log(`✅ 服务正在运行 (PID: ${status.pid})`); - - // 显示进程信息 - exec(`ps -p ${status.pid} -o pid,ppid,pcpu,pmem,etime,cmd --no-headers`, (error, stdout) => { - if (!error && stdout.trim()) { - console.log('\n📊 进程信息:'); - console.log('PID\tPPID\tCPU%\tMEM%\tTIME\t\tCOMMAND'); - console.log(stdout.trim()); - } - }); - } else { - console.log('❌ 服务未运行'); - } - return status.running; - } + exec(`tail -n ${lines} ${LOG_FILE}`, (error, stdout) => { + if (error) { + console.error('读取日志失败:', error.message) + return + } + console.log(stdout) + }) + } - logs(lines = 50) { - console.log(`📖 最近 ${lines} 行日志:\n`); - - exec(`tail -n ${lines} ${LOG_FILE}`, (error, stdout) => { - if (error) { - console.error('读取日志失败:', error.message); - return; - } - console.log(stdout); - }); - } - - help() { - console.log(` + help() { + console.log(` 🔧 Claude Relay Service 进程管理器 用法: npm run service [options] @@ -281,55 +278,56 @@ class ServiceManager { PID文件: ${PID_FILE} 日志文件: ${LOG_FILE} 错误日志: ${ERROR_LOG_FILE} - `); - } + `) + } } // 主程序 function main() { - const manager = new ServiceManager(); - const args = process.argv.slice(2); - const command = args[0]; - const isDaemon = args.includes('-d') || args.includes('--daemon'); + const manager = new ServiceManager() + const args = process.argv.slice(2) + const command = args[0] + const isDaemon = args.includes('-d') || args.includes('--daemon') - switch (command) { - case 'start': - case 's': - manager.start(isDaemon); - break; - case 'stop': - case 'halt': - manager.stop(); - break; - case 'restart': - case 'r': - manager.restart(isDaemon); - break; - case 'status': - case 'st': - manager.status(); - break; - case 'logs': - case 'log': - case 'l': - const lines = parseInt(args[1]) || 50; - manager.logs(lines); - break; - case 'help': - case '--help': - case '-h': - case 'h': - manager.help(); - break; - default: - console.log('❌ 未知命令:', command); - manager.help(); - process.exit(1); + switch (command) { + case 'start': + case 's': + manager.start(isDaemon) + break + case 'stop': + case 'halt': + manager.stop() + break + case 'restart': + case 'r': + manager.restart(isDaemon) + break + case 'status': + case 'st': + manager.status() + break + case 'logs': + case 'log': + case 'l': { + const lines = parseInt(args[1]) || 50 + manager.logs(lines) + break } + case 'help': + case '--help': + case '-h': + case 'h': + manager.help() + break + default: + console.log('❌ 未知命令:', command) + manager.help() + process.exit(1) + } } if (require.main === module) { - main(); + main() } -module.exports = ServiceManager; \ No newline at end of file +module.exports = ServiceManager diff --git a/scripts/migrate-apikey-expiry.js b/scripts/migrate-apikey-expiry.js index a7d85fc2..1be8a82e 100644 --- a/scripts/migrate-apikey-expiry.js +++ b/scripts/migrate-apikey-expiry.js @@ -2,58 +2,58 @@ /** * 数据迁移脚本:为现有 API Key 设置默认有效期 - * + * * 使用方法: * node scripts/migrate-apikey-expiry.js [--days=30] [--dry-run] - * + * * 参数: * --days: 设置默认有效期天数(默认30天) * --dry-run: 仅模拟运行,不实际修改数据 */ -const redis = require('../src/models/redis'); -const logger = require('../src/utils/logger'); -const readline = require('readline'); +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') +const readline = require('readline') // 解析命令行参数 -const args = process.argv.slice(2); -const params = {}; -args.forEach(arg => { - const [key, value] = arg.split('='); - params[key.replace('--', '')] = value || true; -}); +const args = process.argv.slice(2) +const params = {} +args.forEach((arg) => { + const [key, value] = arg.split('=') + params[key.replace('--', '')] = value || true +}) -const DEFAULT_DAYS = params.days ? parseInt(params.days) : 30; -const DRY_RUN = params['dry-run'] === true; +const DEFAULT_DAYS = params.days ? parseInt(params.days) : 30 +const DRY_RUN = params['dry-run'] === true // 创建 readline 接口用于用户确认 const rl = readline.createInterface({ input: process.stdin, output: process.stdout -}); +}) async function askConfirmation(question) { return new Promise((resolve) => { - rl.question(question + ' (yes/no): ', (answer) => { - resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y'); - }); - }); + rl.question(`${question} (yes/no): `, (answer) => { + resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y') + }) + }) } async function migrateApiKeys() { try { - logger.info('🔄 Starting API Key expiry migration...'); - logger.info(`📅 Default expiry period: ${DEFAULT_DAYS} days`); - logger.info(`🔍 Mode: ${DRY_RUN ? 'DRY RUN (no changes will be made)' : 'LIVE RUN'}`); - + logger.info('🔄 Starting API Key expiry migration...') + logger.info(`📅 Default expiry period: ${DEFAULT_DAYS} days`) + logger.info(`🔍 Mode: ${DRY_RUN ? 'DRY RUN (no changes will be made)' : 'LIVE RUN'}`) + // 连接 Redis - await redis.connect(); - logger.success('✅ Connected to Redis'); - + await redis.connect() + logger.success('✅ Connected to Redis') + // 获取所有 API Keys - const apiKeys = await redis.getAllApiKeys(); - logger.info(`📊 Found ${apiKeys.length} API Keys in total`); - + const apiKeys = await redis.getAllApiKeys() + logger.info(`📊 Found ${apiKeys.length} API Keys in total`) + // 统计信息 const stats = { total: apiKeys.length, @@ -61,98 +61,99 @@ async function migrateApiKeys() { alreadyHasExpiry: 0, migrated: 0, errors: 0 - }; - + } + // 需要迁移的 Keys - const keysToMigrate = []; - + const keysToMigrate = [] + // 分析每个 API Key for (const key of apiKeys) { if (!key.expiresAt || key.expiresAt === 'null' || key.expiresAt === '') { - keysToMigrate.push(key); - stats.needsMigration++; - logger.info(`📌 API Key "${key.name}" (${key.id}) needs migration`); + keysToMigrate.push(key) + stats.needsMigration++ + logger.info(`📌 API Key "${key.name}" (${key.id}) needs migration`) } else { - stats.alreadyHasExpiry++; - const expiryDate = new Date(key.expiresAt); - logger.info(`✓ API Key "${key.name}" (${key.id}) already has expiry: ${expiryDate.toLocaleString()}`); + stats.alreadyHasExpiry++ + const expiryDate = new Date(key.expiresAt) + logger.info( + `✓ API Key "${key.name}" (${key.id}) already has expiry: ${expiryDate.toLocaleString()}` + ) } } - + if (keysToMigrate.length === 0) { - logger.success('✨ No API Keys need migration!'); - return; + logger.success('✨ No API Keys need migration!') + return } - + // 显示迁移摘要 - console.log('\n' + '='.repeat(60)); - console.log('📋 Migration Summary:'); - console.log('='.repeat(60)); - console.log(`Total API Keys: ${stats.total}`); - console.log(`Already have expiry: ${stats.alreadyHasExpiry}`); - console.log(`Need migration: ${stats.needsMigration}`); - console.log(`Default expiry: ${DEFAULT_DAYS} days from now`); - console.log('='.repeat(60) + '\n'); - + console.log(`\n${'='.repeat(60)}`) + console.log('📋 Migration Summary:') + console.log('='.repeat(60)) + console.log(`Total API Keys: ${stats.total}`) + console.log(`Already have expiry: ${stats.alreadyHasExpiry}`) + console.log(`Need migration: ${stats.needsMigration}`) + console.log(`Default expiry: ${DEFAULT_DAYS} days from now`) + console.log(`${'='.repeat(60)}\n`) + // 如果不是 dry run,请求确认 if (!DRY_RUN) { const confirmed = await askConfirmation( `⚠️ This will set expiry dates for ${keysToMigrate.length} API Keys. Continue?` - ); - + ) + if (!confirmed) { - logger.warn('❌ Migration cancelled by user'); - return; + logger.warn('❌ Migration cancelled by user') + return } } - + // 计算新的过期时间 - const newExpiryDate = new Date(); - newExpiryDate.setDate(newExpiryDate.getDate() + DEFAULT_DAYS); - const newExpiryISO = newExpiryDate.toISOString(); - - logger.info(`\n🚀 Starting migration... New expiry date: ${newExpiryDate.toLocaleString()}`); - + const newExpiryDate = new Date() + newExpiryDate.setDate(newExpiryDate.getDate() + DEFAULT_DAYS) + const newExpiryISO = newExpiryDate.toISOString() + + logger.info(`\n🚀 Starting migration... New expiry date: ${newExpiryDate.toLocaleString()}`) + // 执行迁移 for (const key of keysToMigrate) { try { if (!DRY_RUN) { // 直接更新 Redis 中的数据 // 使用 hset 更新单个字段 - await redis.client.hset(`apikey:${key.id}`, 'expiresAt', newExpiryISO); - logger.success(`✅ Migrated: "${key.name}" (${key.id})`); + await redis.client.hset(`apikey:${key.id}`, 'expiresAt', newExpiryISO) + logger.success(`✅ Migrated: "${key.name}" (${key.id})`) } else { - logger.info(`[DRY RUN] Would migrate: "${key.name}" (${key.id})`); + logger.info(`[DRY RUN] Would migrate: "${key.name}" (${key.id})`) } - stats.migrated++; + stats.migrated++ } catch (error) { - logger.error(`❌ Error migrating "${key.name}" (${key.id}):`, error.message); - stats.errors++; + logger.error(`❌ Error migrating "${key.name}" (${key.id}):`, error.message) + stats.errors++ } } - + // 显示最终结果 - console.log('\n' + '='.repeat(60)); - console.log('✅ Migration Complete!'); - console.log('='.repeat(60)); - console.log(`Successfully migrated: ${stats.migrated}`); - console.log(`Errors: ${stats.errors}`); - console.log(`New expiry date: ${newExpiryDate.toLocaleString()}`); - console.log('='.repeat(60) + '\n'); - + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Migration Complete!') + console.log('='.repeat(60)) + console.log(`Successfully migrated: ${stats.migrated}`) + console.log(`Errors: ${stats.errors}`) + console.log(`New expiry date: ${newExpiryDate.toLocaleString()}`) + console.log(`${'='.repeat(60)}\n`) + if (DRY_RUN) { - logger.warn('⚠️ This was a DRY RUN. No actual changes were made.'); - logger.info('💡 Run without --dry-run flag to apply changes.'); + logger.warn('⚠️ This was a DRY RUN. No actual changes were made.') + logger.info('💡 Run without --dry-run flag to apply changes.') } - } catch (error) { - logger.error('💥 Migration failed:', error); - process.exit(1); + logger.error('💥 Migration failed:', error) + process.exit(1) } finally { // 清理 - rl.close(); - await redis.disconnect(); - logger.info('👋 Disconnected from Redis'); + rl.close() + await redis.disconnect() + logger.info('👋 Disconnected from Redis') } } @@ -180,12 +181,12 @@ Examples: # Test run without making changes node scripts/migrate-apikey-expiry.js --dry-run -`); - process.exit(0); +`) + process.exit(0) } // 运行迁移 -migrateApiKeys().catch(error => { - logger.error('💥 Unexpected error:', error); - process.exit(1); -}); \ No newline at end of file +migrateApiKeys().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/setup.js b/scripts/setup.js index 16947b19..1ad76a9b 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -1,123 +1,128 @@ -const fs = require('fs'); -const path = require('path'); -const crypto = require('crypto'); -const bcrypt = require('bcryptjs'); -const chalk = require('chalk'); -const ora = require('ora'); +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') +const chalk = require('chalk') +const ora = require('ora') -const config = require('../config/config'); +const config = require('../config/config') async function setup() { - console.log(chalk.blue.bold('\n🚀 Claude Relay Service 初始化设置\n')); - - const spinner = ora('正在进行初始化设置...').start(); - + console.log(chalk.blue.bold('\n🚀 Claude Relay Service 初始化设置\n')) + + const spinner = ora('正在进行初始化设置...').start() + try { // 1. 创建必要目录 - const directories = [ - 'logs', - 'data', - 'temp' - ]; - - directories.forEach(dir => { - const dirPath = path.join(__dirname, '..', dir); + const directories = ['logs', 'data', 'temp'] + + directories.forEach((dir) => { + const dirPath = path.join(__dirname, '..', dir) if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); + fs.mkdirSync(dirPath, { recursive: true }) } - }); - + }) + // 2. 生成环境配置文件 if (!fs.existsSync(path.join(__dirname, '..', '.env'))) { - const envTemplate = fs.readFileSync(path.join(__dirname, '..', '.env.example'), 'utf8'); - + const envTemplate = fs.readFileSync(path.join(__dirname, '..', '.env.example'), 'utf8') + // 生成随机密钥 - const jwtSecret = crypto.randomBytes(64).toString('hex'); - const encryptionKey = crypto.randomBytes(32).toString('hex'); - + const jwtSecret = crypto.randomBytes(64).toString('hex') + const encryptionKey = crypto.randomBytes(32).toString('hex') + const envContent = envTemplate .replace('your-jwt-secret-here', jwtSecret) - .replace('your-encryption-key-here', encryptionKey); - - fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent); + .replace('your-encryption-key-here', encryptionKey) + + fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent) } - + // 3. 生成或使用环境变量中的管理员凭据 - const adminUsername = process.env.ADMIN_USERNAME || `cr_admin_${crypto.randomBytes(4).toString('hex')}`; - const adminPassword = process.env.ADMIN_PASSWORD || crypto.randomBytes(16).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 16); - + const adminUsername = + process.env.ADMIN_USERNAME || `cr_admin_${crypto.randomBytes(4).toString('hex')}` + const adminPassword = + process.env.ADMIN_PASSWORD || + crypto + .randomBytes(16) + .toString('base64') + .replace(/[^a-zA-Z0-9]/g, '') + .substring(0, 16) + // 如果使用了环境变量,显示提示 if (process.env.ADMIN_USERNAME || process.env.ADMIN_PASSWORD) { - console.log(chalk.yellow('\n📌 使用环境变量中的管理员凭据')); + console.log(chalk.yellow('\n📌 使用环境变量中的管理员凭据')) } - + // 4. 创建初始化完成标记文件 const initData = { initializedAt: new Date().toISOString(), adminUsername, adminPassword, version: '1.0.0' - }; - + } + fs.writeFileSync( - path.join(__dirname, '..', 'data', 'init.json'), + path.join(__dirname, '..', 'data', 'init.json'), JSON.stringify(initData, null, 2) - ); - - spinner.succeed('初始化设置完成'); - - console.log(chalk.green('\n✅ 设置完成!\n')); - console.log(chalk.yellow('📋 重要信息:\n')); - console.log(` 管理员用户名: ${chalk.cyan(adminUsername)}`); - console.log(` 管理员密码: ${chalk.cyan(adminPassword)}`); - + ) + + spinner.succeed('初始化设置完成') + + console.log(chalk.green('\n✅ 设置完成!\n')) + console.log(chalk.yellow('📋 重要信息:\n')) + console.log(` 管理员用户名: ${chalk.cyan(adminUsername)}`) + console.log(` 管理员密码: ${chalk.cyan(adminPassword)}`) + // 如果是自动生成的凭据,强调需要保存 if (!process.env.ADMIN_USERNAME && !process.env.ADMIN_PASSWORD) { - console.log(chalk.red('\n⚠️ 请立即保存这些凭据!首次登录后建议修改密码。')); - console.log(chalk.yellow('\n💡 提示: 也可以通过环境变量 ADMIN_USERNAME 和 ADMIN_PASSWORD 预设管理员凭据。\n')); + console.log(chalk.red('\n⚠️ 请立即保存这些凭据!首次登录后建议修改密码。')) + console.log( + chalk.yellow( + '\n💡 提示: 也可以通过环境变量 ADMIN_USERNAME 和 ADMIN_PASSWORD 预设管理员凭据。\n' + ) + ) } else { - console.log(chalk.green('\n✅ 已使用预设的管理员凭据。\n')); + console.log(chalk.green('\n✅ 已使用预设的管理员凭据。\n')) } - - console.log(chalk.blue('🚀 启动服务:\n')); - console.log(' npm start - 启动生产服务'); - console.log(' npm run dev - 启动开发服务'); - console.log(' npm run cli admin - 管理员CLI工具\n'); - - console.log(chalk.blue('🌐 访问地址:\n')); - console.log(` Web管理界面: http://localhost:${config.server.port}/web`); - console.log(` API端点: http://localhost:${config.server.port}/api/v1/messages`); - console.log(` 健康检查: http://localhost:${config.server.port}/health\n`); - + + console.log(chalk.blue('🚀 启动服务:\n')) + console.log(' npm start - 启动生产服务') + console.log(' npm run dev - 启动开发服务') + console.log(' npm run cli admin - 管理员CLI工具\n') + + console.log(chalk.blue('🌐 访问地址:\n')) + console.log(` Web管理界面: http://localhost:${config.server.port}/web`) + console.log(` API端点: http://localhost:${config.server.port}/api/v1/messages`) + console.log(` 健康检查: http://localhost:${config.server.port}/health\n`) } catch (error) { - spinner.fail('初始化设置失败'); - console.error(chalk.red('❌ 错误:'), error.message); - process.exit(1); + spinner.fail('初始化设置失败') + console.error(chalk.red('❌ 错误:'), error.message) + process.exit(1) } } // 检查是否已初始化 function checkInitialized() { - const initFile = path.join(__dirname, '..', 'data', 'init.json'); + const initFile = path.join(__dirname, '..', 'data', 'init.json') if (fs.existsSync(initFile)) { - const initData = JSON.parse(fs.readFileSync(initFile, 'utf8')); - console.log(chalk.yellow('⚠️ 服务已经初始化过了!')); - console.log(` 初始化时间: ${new Date(initData.initializedAt).toLocaleString()}`); - console.log(` 管理员用户名: ${initData.adminUsername}`); - console.log('\n如需重新初始化,请删除 data/init.json 文件后再运行此命令。'); - console.log(chalk.red('\n⚠️ 重要提示:')); - console.log(' 1. 删除 init.json 文件后运行 npm run setup'); - console.log(' 2. 生成新的账号密码后,需要重启服务才能生效'); - console.log(' 3. 使用 npm run service:restart 重启服务\n'); - return true; + const initData = JSON.parse(fs.readFileSync(initFile, 'utf8')) + console.log(chalk.yellow('⚠️ 服务已经初始化过了!')) + console.log(` 初始化时间: ${new Date(initData.initializedAt).toLocaleString()}`) + console.log(` 管理员用户名: ${initData.adminUsername}`) + console.log('\n如需重新初始化,请删除 data/init.json 文件后再运行此命令。') + console.log(chalk.red('\n⚠️ 重要提示:')) + console.log(' 1. 删除 init.json 文件后运行 npm run setup') + console.log(' 2. 生成新的账号密码后,需要重启服务才能生效') + console.log(' 3. 使用 npm run service:restart 重启服务\n') + return true } - return false; + return false } if (require.main === module) { if (!checkInitialized()) { - setup(); + setup() } } -module.exports = { setup, checkInitialized }; \ No newline at end of file +module.exports = { setup, checkInitialized } diff --git a/scripts/test-account-display.js b/scripts/test-account-display.js index cd36647a..de5daad0 100644 --- a/scripts/test-account-display.js +++ b/scripts/test-account-display.js @@ -2,143 +2,142 @@ * 测试账号显示问题是否已修复 */ -const axios = require('axios'); -const config = require('../config/config'); +const axios = require('axios') +const config = require('../config/config') // 从 init.json 读取管理员凭据 -const fs = require('fs'); -const path = require('path'); +const fs = require('fs') +const path = require('path') async function testAccountDisplay() { - console.log('🔍 测试账号显示问题...\n'); + console.log('🔍 测试账号显示问题...\n') try { // 读取管理员凭据 - const initPath = path.join(__dirname, '..', 'config', 'init.json'); + const initPath = path.join(__dirname, '..', 'config', 'init.json') if (!fs.existsSync(initPath)) { - console.error('❌ 找不到 init.json 文件,请运行 npm run setup'); - process.exit(1); + console.error('❌ 找不到 init.json 文件,请运行 npm run setup') + process.exit(1) } - const initData = JSON.parse(fs.readFileSync(initPath, 'utf8')); - const adminUser = initData.admins?.[0]; + const initData = JSON.parse(fs.readFileSync(initPath, 'utf8')) + const adminUser = initData.admins?.[0] if (!adminUser) { - console.error('❌ 没有找到管理员账号'); - process.exit(1); + console.error('❌ 没有找到管理员账号') + process.exit(1) } - const baseURL = `http://localhost:${config.server.port}`; - + const baseURL = `http://localhost:${config.server.port}` + // 登录获取 token - console.log('🔐 登录管理员账号...'); + console.log('🔐 登录管理员账号...') const loginResp = await axios.post(`${baseURL}/admin/login`, { username: adminUser.username, password: adminUser.password - }); + }) if (!loginResp.data.success) { - console.error('❌ 登录失败'); - process.exit(1); + console.error('❌ 登录失败') + process.exit(1) } - const token = loginResp.data.token; - console.log('✅ 登录成功\n'); + const { token } = loginResp.data + console.log('✅ 登录成功\n') // 设置请求头 const headers = { - 'Authorization': `Bearer ${token}`, + Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' - }; + } // 获取 Claude OAuth 账号 - console.log('📋 获取 Claude OAuth 账号...'); - const claudeResp = await axios.get(`${baseURL}/admin/claude-accounts`, { headers }); - const claudeAccounts = claudeResp.data.data || []; - - console.log(`找到 ${claudeAccounts.length} 个 Claude OAuth 账号`); - + console.log('📋 获取 Claude OAuth 账号...') + const claudeResp = await axios.get(`${baseURL}/admin/claude-accounts`, { headers }) + const claudeAccounts = claudeResp.data.data || [] + + console.log(`找到 ${claudeAccounts.length} 个 Claude OAuth 账号`) + // 分类显示 - const claudeDedicated = claudeAccounts.filter(a => a.accountType === 'dedicated'); - const claudeGroup = claudeAccounts.filter(a => a.accountType === 'group'); - const claudeShared = claudeAccounts.filter(a => a.accountType === 'shared'); - - console.log(`- 专属账号: ${claudeDedicated.length} 个`); - console.log(`- 分组账号: ${claudeGroup.length} 个`); - console.log(`- 共享账号: ${claudeShared.length} 个`); - + const claudeDedicated = claudeAccounts.filter((a) => a.accountType === 'dedicated') + const claudeGroup = claudeAccounts.filter((a) => a.accountType === 'group') + const claudeShared = claudeAccounts.filter((a) => a.accountType === 'shared') + + console.log(`- 专属账号: ${claudeDedicated.length} 个`) + console.log(`- 分组账号: ${claudeGroup.length} 个`) + console.log(`- 共享账号: ${claudeShared.length} 个`) + // 检查 platform 字段 - console.log('\n检查 platform 字段:'); - claudeAccounts.slice(0, 3).forEach(acc => { - console.log(`- ${acc.name}: platform=${acc.platform}, accountType=${acc.accountType}`); - }); - + console.log('\n检查 platform 字段:') + claudeAccounts.slice(0, 3).forEach((acc) => { + console.log(`- ${acc.name}: platform=${acc.platform}, accountType=${acc.accountType}`) + }) + // 获取 Claude Console 账号 - console.log('\n📋 获取 Claude Console 账号...'); - const consoleResp = await axios.get(`${baseURL}/admin/claude-console-accounts`, { headers }); - const consoleAccounts = consoleResp.data.data || []; - - console.log(`找到 ${consoleAccounts.length} 个 Claude Console 账号`); - + console.log('\n📋 获取 Claude Console 账号...') + const consoleResp = await axios.get(`${baseURL}/admin/claude-console-accounts`, { headers }) + const consoleAccounts = consoleResp.data.data || [] + + console.log(`找到 ${consoleAccounts.length} 个 Claude Console 账号`) + // 分类显示 - const consoleDedicated = consoleAccounts.filter(a => a.accountType === 'dedicated'); - const consoleGroup = consoleAccounts.filter(a => a.accountType === 'group'); - const consoleShared = consoleAccounts.filter(a => a.accountType === 'shared'); - - console.log(`- 专属账号: ${consoleDedicated.length} 个`); - console.log(`- 分组账号: ${consoleGroup.length} 个`); - console.log(`- 共享账号: ${consoleShared.length} 个`); - + const consoleDedicated = consoleAccounts.filter((a) => a.accountType === 'dedicated') + const consoleGroup = consoleAccounts.filter((a) => a.accountType === 'group') + const consoleShared = consoleAccounts.filter((a) => a.accountType === 'shared') + + console.log(`- 专属账号: ${consoleDedicated.length} 个`) + console.log(`- 分组账号: ${consoleGroup.length} 个`) + console.log(`- 共享账号: ${consoleShared.length} 个`) + // 检查 platform 字段 - console.log('\n检查 platform 字段:'); - consoleAccounts.slice(0, 3).forEach(acc => { - console.log(`- ${acc.name}: platform=${acc.platform}, accountType=${acc.accountType}`); - }); - + console.log('\n检查 platform 字段:') + consoleAccounts.slice(0, 3).forEach((acc) => { + console.log(`- ${acc.name}: platform=${acc.platform}, accountType=${acc.accountType}`) + }) + // 获取账号分组 - console.log('\n📋 获取账号分组...'); - const groupsResp = await axios.get(`${baseURL}/admin/account-groups`, { headers }); - const groups = groupsResp.data.data || []; - - console.log(`找到 ${groups.length} 个账号分组`); - - const claudeGroups = groups.filter(g => g.platform === 'claude'); - const geminiGroups = groups.filter(g => g.platform === 'gemini'); - - console.log(`- Claude 分组: ${claudeGroups.length} 个`); - console.log(`- Gemini 分组: ${geminiGroups.length} 个`); - + console.log('\n📋 获取账号分组...') + const groupsResp = await axios.get(`${baseURL}/admin/account-groups`, { headers }) + const groups = groupsResp.data.data || [] + + console.log(`找到 ${groups.length} 个账号分组`) + + const claudeGroups = groups.filter((g) => g.platform === 'claude') + const geminiGroups = groups.filter((g) => g.platform === 'gemini') + + console.log(`- Claude 分组: ${claudeGroups.length} 个`) + console.log(`- Gemini 分组: ${geminiGroups.length} 个`) + // 测试结果总结 - console.log('\n📊 测试结果总结:'); - console.log('✅ Claude OAuth 账号已包含 platform 字段'); - console.log('✅ Claude Console 账号已包含 platform 字段'); - console.log('✅ 账号分组功能正常'); - - const totalDedicated = claudeDedicated.length + consoleDedicated.length; - const totalGroups = claudeGroups.length; - + console.log('\n📊 测试结果总结:') + console.log('✅ Claude OAuth 账号已包含 platform 字段') + console.log('✅ Claude Console 账号已包含 platform 字段') + console.log('✅ 账号分组功能正常') + + const totalDedicated = claudeDedicated.length + consoleDedicated.length + const totalGroups = claudeGroups.length + if (totalDedicated > 0) { - console.log(`\n✅ 共有 ${totalDedicated} 个专属账号应该显示在下拉框中`); + console.log(`\n✅ 共有 ${totalDedicated} 个专属账号应该显示在下拉框中`) } else { - console.log('\n⚠️ 没有找到专属账号,请在账号管理页面设置账号类型为"专属账户"'); + console.log('\n⚠️ 没有找到专属账号,请在账号管理页面设置账号类型为"专属账户"') } - + if (totalGroups > 0) { - console.log(`✅ 共有 ${totalGroups} 个分组应该显示在下拉框中`); + console.log(`✅ 共有 ${totalGroups} 个分组应该显示在下拉框中`) } - - console.log('\n💡 请在浏览器中测试创建/编辑 API Key,检查下拉框是否正确显示三个类别:'); - console.log(' 1. 调度分组'); - console.log(' 2. Claude OAuth 账号'); - console.log(' 3. Claude Console 账号'); - + + console.log('\n💡 请在浏览器中测试创建/编辑 API Key,检查下拉框是否正确显示三个类别:') + console.log(' 1. 调度分组') + console.log(' 2. Claude OAuth 账号') + console.log(' 3. Claude Console 账号') } catch (error) { - console.error('❌ 测试失败:', error.message); + console.error('❌ 测试失败:', error.message) if (error.response) { - console.error('响应数据:', error.response.data); + console.error('响应数据:', error.response.data) } } finally { - process.exit(0); + process.exit(0) } } -testAccountDisplay(); \ No newline at end of file +testAccountDisplay() diff --git a/scripts/test-api-response.js b/scripts/test-api-response.js index dfdf1c3c..02453708 100644 --- a/scripts/test-api-response.js +++ b/scripts/test-api-response.js @@ -2,127 +2,140 @@ * 测试 API 响应中的账号数据 */ -const redis = require('../src/models/redis'); -const claudeAccountService = require('../src/services/claudeAccountService'); -const claudeConsoleAccountService = require('../src/services/claudeConsoleAccountService'); -const accountGroupService = require('../src/services/accountGroupService'); +const redis = require('../src/models/redis') +const claudeAccountService = require('../src/services/claudeAccountService') +const claudeConsoleAccountService = require('../src/services/claudeConsoleAccountService') +const accountGroupService = require('../src/services/accountGroupService') async function testApiResponse() { - console.log('🔍 测试 API 响应数据...\n'); + console.log('🔍 测试 API 响应数据...\n') try { // 确保 Redis 已连接 - await redis.connect(); - + await redis.connect() + // 1. 测试 Claude OAuth 账号服务 - console.log('📋 测试 Claude OAuth 账号服务...'); - const claudeAccounts = await claudeAccountService.getAllAccounts(); - console.log(`找到 ${claudeAccounts.length} 个 Claude OAuth 账号`); - + console.log('📋 测试 Claude OAuth 账号服务...') + const claudeAccounts = await claudeAccountService.getAllAccounts() + console.log(`找到 ${claudeAccounts.length} 个 Claude OAuth 账号`) + // 检查前3个账号的数据结构 - console.log('\n账号数据结构示例:'); - claudeAccounts.slice(0, 3).forEach(acc => { - console.log(`\n账号: ${acc.name}`); - console.log(` - ID: ${acc.id}`); - console.log(` - accountType: ${acc.accountType}`); - console.log(` - platform: ${acc.platform}`); - console.log(` - status: ${acc.status}`); - console.log(` - isActive: ${acc.isActive}`); - }); - + console.log('\n账号数据结构示例:') + claudeAccounts.slice(0, 3).forEach((acc) => { + console.log(`\n账号: ${acc.name}`) + console.log(` - ID: ${acc.id}`) + console.log(` - accountType: ${acc.accountType}`) + console.log(` - platform: ${acc.platform}`) + console.log(` - status: ${acc.status}`) + console.log(` - isActive: ${acc.isActive}`) + }) + // 统计专属账号 - const claudeDedicated = claudeAccounts.filter(a => a.accountType === 'dedicated'); - const claudeGroup = claudeAccounts.filter(a => a.accountType === 'group'); - - console.log(`\n统计结果:`); - console.log(` - 专属账号: ${claudeDedicated.length} 个`); - console.log(` - 分组账号: ${claudeGroup.length} 个`); - + const claudeDedicated = claudeAccounts.filter((a) => a.accountType === 'dedicated') + const claudeGroup = claudeAccounts.filter((a) => a.accountType === 'group') + + console.log('\n统计结果:') + console.log(` - 专属账号: ${claudeDedicated.length} 个`) + console.log(` - 分组账号: ${claudeGroup.length} 个`) + // 2. 测试 Claude Console 账号服务 - console.log('\n\n📋 测试 Claude Console 账号服务...'); - const consoleAccounts = await claudeConsoleAccountService.getAllAccounts(); - console.log(`找到 ${consoleAccounts.length} 个 Claude Console 账号`); - + console.log('\n\n📋 测试 Claude Console 账号服务...') + const consoleAccounts = await claudeConsoleAccountService.getAllAccounts() + console.log(`找到 ${consoleAccounts.length} 个 Claude Console 账号`) + // 检查前3个账号的数据结构 - console.log('\n账号数据结构示例:'); - consoleAccounts.slice(0, 3).forEach(acc => { - console.log(`\n账号: ${acc.name}`); - console.log(` - ID: ${acc.id}`); - console.log(` - accountType: ${acc.accountType}`); - console.log(` - platform: ${acc.platform}`); - console.log(` - status: ${acc.status}`); - console.log(` - isActive: ${acc.isActive}`); - }); - + console.log('\n账号数据结构示例:') + consoleAccounts.slice(0, 3).forEach((acc) => { + console.log(`\n账号: ${acc.name}`) + console.log(` - ID: ${acc.id}`) + console.log(` - accountType: ${acc.accountType}`) + console.log(` - platform: ${acc.platform}`) + console.log(` - status: ${acc.status}`) + console.log(` - isActive: ${acc.isActive}`) + }) + // 统计专属账号 - const consoleDedicated = consoleAccounts.filter(a => a.accountType === 'dedicated'); - const consoleGroup = consoleAccounts.filter(a => a.accountType === 'group'); - - console.log(`\n统计结果:`); - console.log(` - 专属账号: ${consoleDedicated.length} 个`); - console.log(` - 分组账号: ${consoleGroup.length} 个`); - + const consoleDedicated = consoleAccounts.filter((a) => a.accountType === 'dedicated') + const consoleGroup = consoleAccounts.filter((a) => a.accountType === 'group') + + console.log('\n统计结果:') + console.log(` - 专属账号: ${consoleDedicated.length} 个`) + console.log(` - 分组账号: ${consoleGroup.length} 个`) + // 3. 测试账号分组服务 - console.log('\n\n📋 测试账号分组服务...'); - const groups = await accountGroupService.getAllGroups(); - console.log(`找到 ${groups.length} 个账号分组`); - + console.log('\n\n📋 测试账号分组服务...') + const groups = await accountGroupService.getAllGroups() + console.log(`找到 ${groups.length} 个账号分组`) + // 显示分组信息 - groups.forEach(group => { - console.log(`\n分组: ${group.name}`); - console.log(` - ID: ${group.id}`); - console.log(` - platform: ${group.platform}`); - console.log(` - memberCount: ${group.memberCount}`); - }); - + groups.forEach((group) => { + console.log(`\n分组: ${group.name}`) + console.log(` - ID: ${group.id}`) + console.log(` - platform: ${group.platform}`) + console.log(` - memberCount: ${group.memberCount}`) + }) + // 4. 验证结果 - console.log('\n\n📊 验证结果:'); - + console.log('\n\n📊 验证结果:') + // 检查 platform 字段 - const claudeWithPlatform = claudeAccounts.filter(a => a.platform === 'claude-oauth'); - const consoleWithPlatform = consoleAccounts.filter(a => a.platform === 'claude-console'); - + const claudeWithPlatform = claudeAccounts.filter((a) => a.platform === 'claude-oauth') + const consoleWithPlatform = consoleAccounts.filter((a) => a.platform === 'claude-console') + if (claudeWithPlatform.length === claudeAccounts.length) { - console.log('✅ 所有 Claude OAuth 账号都有正确的 platform 字段'); + console.log('✅ 所有 Claude OAuth 账号都有正确的 platform 字段') } else { - console.log(`⚠️ 只有 ${claudeWithPlatform.length}/${claudeAccounts.length} 个 Claude OAuth 账号有正确的 platform 字段`); + console.log( + `⚠️ 只有 ${claudeWithPlatform.length}/${claudeAccounts.length} 个 Claude OAuth 账号有正确的 platform 字段` + ) } - + if (consoleWithPlatform.length === consoleAccounts.length) { - console.log('✅ 所有 Claude Console 账号都有正确的 platform 字段'); + console.log('✅ 所有 Claude Console 账号都有正确的 platform 字段') } else { - console.log(`⚠️ 只有 ${consoleWithPlatform.length}/${consoleAccounts.length} 个 Claude Console 账号有正确的 platform 字段`); + console.log( + `⚠️ 只有 ${consoleWithPlatform.length}/${consoleAccounts.length} 个 Claude Console 账号有正确的 platform 字段` + ) } - + // 总结 - const totalDedicated = claudeDedicated.length + consoleDedicated.length; - const totalGroup = claudeGroup.length + consoleGroup.length; - const totalGroups = groups.filter(g => g.platform === 'claude').length; - - console.log('\n📈 总结:'); - console.log(`- 专属账号总数: ${totalDedicated} 个 (Claude OAuth: ${claudeDedicated.length}, Console: ${consoleDedicated.length})`); - console.log(`- 分组账号总数: ${totalGroup} 个 (Claude OAuth: ${claudeGroup.length}, Console: ${consoleGroup.length})`); - console.log(`- 账号分组总数: ${totalGroups} 个`); - + const totalDedicated = claudeDedicated.length + consoleDedicated.length + const totalGroup = claudeGroup.length + consoleGroup.length + const totalGroups = groups.filter((g) => g.platform === 'claude').length + + console.log('\n📈 总结:') + console.log( + `- 专属账号总数: ${totalDedicated} 个 (Claude OAuth: ${claudeDedicated.length}, Console: ${consoleDedicated.length})` + ) + console.log( + `- 分组账号总数: ${totalGroup} 个 (Claude OAuth: ${claudeGroup.length}, Console: ${consoleGroup.length})` + ) + console.log(`- 账号分组总数: ${totalGroups} 个`) + if (totalDedicated + totalGroups > 0) { - console.log('\n✅ 前端下拉框应该能够显示:'); - if (totalGroups > 0) console.log(' - 调度分组'); - if (claudeDedicated.length > 0) console.log(' - Claude OAuth 专属账号 (仅 dedicated 类型)'); - if (consoleDedicated.length > 0) console.log(' - Claude Console 专属账号 (仅 dedicated 类型)'); + console.log('\n✅ 前端下拉框应该能够显示:') + if (totalGroups > 0) { + console.log(' - 调度分组') + } + if (claudeDedicated.length > 0) { + console.log(' - Claude OAuth 专属账号 (仅 dedicated 类型)') + } + if (consoleDedicated.length > 0) { + console.log(' - Claude Console 专属账号 (仅 dedicated 类型)') + } } else { - console.log('\n⚠️ 没有找到任何专属账号或分组,请检查账号配置'); + console.log('\n⚠️ 没有找到任何专属账号或分组,请检查账号配置') } - - console.log('\n💡 说明:'); - console.log('- 专属账号下拉框只显示 accountType="dedicated" 的账号'); - console.log('- accountType="group" 的账号通过分组调度,不在专属账号中显示'); - + + console.log('\n💡 说明:') + console.log('- 专属账号下拉框只显示 accountType="dedicated" 的账号') + console.log('- accountType="group" 的账号通过分组调度,不在专属账号中显示') } catch (error) { - console.error('❌ 测试失败:', error); - console.error(error.stack); + console.error('❌ 测试失败:', error) + console.error(error.stack) } finally { - process.exit(0); + process.exit(0) } } -testApiResponse(); \ No newline at end of file +testApiResponse() diff --git a/scripts/test-bedrock-models.js b/scripts/test-bedrock-models.js index 3d911746..e0d62eff 100644 --- a/scripts/test-bedrock-models.js +++ b/scripts/test-bedrock-models.js @@ -1,35 +1,33 @@ #!/usr/bin/env node -const bedrockAccountService = require('../src/services/bedrockAccountService'); -const bedrockRelayService = require('../src/services/bedrockRelayService'); -const logger = require('../src/utils/logger'); +const bedrockRelayService = require('../src/services/bedrockRelayService') async function testBedrockModels() { try { - console.log('🧪 测试Bedrock模型配置...'); - + console.log('🧪 测试Bedrock模型配置...') + // 测试可用模型列表 - const models = await bedrockRelayService.getAvailableModels(); - console.log(`📋 找到 ${models.length} 个可用模型:`); - models.forEach(model => { - console.log(` - ${model.id} (${model.name})`); - }); + const models = await bedrockRelayService.getAvailableModels() + console.log(`📋 找到 ${models.length} 个可用模型:`) + models.forEach((model) => { + console.log(` - ${model.id} (${model.name})`) + }) // 测试默认模型 - console.log(`\n🎯 系统默认模型: ${bedrockRelayService.defaultModel}`); - console.log(`🎯 系统默认小模型: ${bedrockRelayService.defaultSmallModel}`); - - console.log('\n✅ Bedrock模型配置测试完成'); - process.exit(0); + console.log(`\n🎯 系统默认模型: ${bedrockRelayService.defaultModel}`) + console.log(`🎯 系统默认小模型: ${bedrockRelayService.defaultSmallModel}`) + + console.log('\n✅ Bedrock模型配置测试完成') + process.exit(0) } catch (error) { - console.error('❌ Bedrock模型测试失败:', error); - process.exit(1); + console.error('❌ Bedrock模型测试失败:', error) + process.exit(1) } } // 如果直接运行此脚本 if (require.main === module) { - testBedrockModels(); + testBedrockModels() } -module.exports = { testBedrockModels }; \ No newline at end of file +module.exports = { testBedrockModels } diff --git a/scripts/test-dedicated-accounts.js b/scripts/test-dedicated-accounts.js index 7dcac66e..7b222022 100644 --- a/scripts/test-dedicated-accounts.js +++ b/scripts/test-dedicated-accounts.js @@ -2,131 +2,132 @@ * 测试专属账号显示问题 */ -const redis = require('../src/models/redis'); +const redis = require('../src/models/redis') async function testDedicatedAccounts() { - console.log('🔍 检查专属账号...\n'); + console.log('🔍 检查专属账号...\n') try { // 确保 Redis 已连接 - await redis.connect(); - + await redis.connect() + // 获取所有 Claude 账号 - const claudeKeys = await redis.client.keys('claude:account:*'); - console.log(`找到 ${claudeKeys.length} 个 Claude 账号\n`); - - const dedicatedAccounts = []; - const groupAccounts = []; - const sharedAccounts = []; - + const claudeKeys = await redis.client.keys('claude:account:*') + console.log(`找到 ${claudeKeys.length} 个 Claude 账号\n`) + + const dedicatedAccounts = [] + const groupAccounts = [] + const sharedAccounts = [] + for (const key of claudeKeys) { - const account = await redis.client.hgetall(key); - const accountType = account.accountType || 'shared'; - + const account = await redis.client.hgetall(key) + const accountType = account.accountType || 'shared' + const accountInfo = { id: account.id, name: account.name, - accountType: accountType, + accountType, status: account.status, isActive: account.isActive, createdAt: account.createdAt - }; - + } + if (accountType === 'dedicated') { - dedicatedAccounts.push(accountInfo); + dedicatedAccounts.push(accountInfo) } else if (accountType === 'group') { - groupAccounts.push(accountInfo); + groupAccounts.push(accountInfo) } else { - sharedAccounts.push(accountInfo); + sharedAccounts.push(accountInfo) } } - - console.log('📊 账号统计:'); - console.log(`- 专属账号: ${dedicatedAccounts.length} 个`); - console.log(`- 分组账号: ${groupAccounts.length} 个`); - console.log(`- 共享账号: ${sharedAccounts.length} 个`); - console.log(''); - + + console.log('📊 账号统计:') + console.log(`- 专属账号: ${dedicatedAccounts.length} 个`) + console.log(`- 分组账号: ${groupAccounts.length} 个`) + console.log(`- 共享账号: ${sharedAccounts.length} 个`) + console.log('') + if (dedicatedAccounts.length > 0) { - console.log('✅ 专属账号列表:'); - dedicatedAccounts.forEach(acc => { - console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`); - }); - console.log(''); + console.log('✅ 专属账号列表:') + dedicatedAccounts.forEach((acc) => { + console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`) + }) + console.log('') } else { - console.log('⚠️ 没有找到专属账号!'); - console.log('💡 提示: 请确保在账号管理页面将账号类型设置为"专属账户"'); - console.log(''); + console.log('⚠️ 没有找到专属账号!') + console.log('💡 提示: 请确保在账号管理页面将账号类型设置为"专属账户"') + console.log('') } - + if (groupAccounts.length > 0) { - console.log('📁 分组账号列表:'); - groupAccounts.forEach(acc => { - console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`); - }); - console.log(''); + console.log('📁 分组账号列表:') + groupAccounts.forEach((acc) => { + console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`) + }) + console.log('') } - + // 检查分组 - const groupKeys = await redis.client.keys('account_group:*'); - console.log(`\n找到 ${groupKeys.length} 个账号分组`); - + const groupKeys = await redis.client.keys('account_group:*') + console.log(`\n找到 ${groupKeys.length} 个账号分组`) + if (groupKeys.length > 0) { - console.log('📋 分组列表:'); + console.log('📋 分组列表:') for (const key of groupKeys) { - const group = await redis.client.hgetall(key); - console.log(` - ${group.name} (平台: ${group.platform}, 成员数: ${group.memberCount || 0})`); + const group = await redis.client.hgetall(key) + console.log( + ` - ${group.name} (平台: ${group.platform}, 成员数: ${group.memberCount || 0})` + ) } } - + // 检查 Claude Console 账号 - const consoleKeys = await redis.client.keys('claude_console_account:*'); - console.log(`\n找到 ${consoleKeys.length} 个 Claude Console 账号`); - - const dedicatedConsoleAccounts = []; - const groupConsoleAccounts = []; - + const consoleKeys = await redis.client.keys('claude_console_account:*') + console.log(`\n找到 ${consoleKeys.length} 个 Claude Console 账号`) + + const dedicatedConsoleAccounts = [] + const groupConsoleAccounts = [] + for (const key of consoleKeys) { - const account = await redis.client.hgetall(key); - const accountType = account.accountType || 'shared'; - + const account = await redis.client.hgetall(key) + const accountType = account.accountType || 'shared' + if (accountType === 'dedicated') { dedicatedConsoleAccounts.push({ id: account.id, name: account.name, - accountType: accountType, + accountType, status: account.status - }); + }) } else if (accountType === 'group') { groupConsoleAccounts.push({ id: account.id, name: account.name, - accountType: accountType, + accountType, status: account.status - }); + }) } } - + if (dedicatedConsoleAccounts.length > 0) { - console.log('\n✅ Claude Console 专属账号:'); - dedicatedConsoleAccounts.forEach(acc => { - console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`); - }); + console.log('\n✅ Claude Console 专属账号:') + dedicatedConsoleAccounts.forEach((acc) => { + console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`) + }) } - + if (groupConsoleAccounts.length > 0) { - console.log('\n📁 Claude Console 分组账号:'); - groupConsoleAccounts.forEach(acc => { - console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`); - }); + console.log('\n📁 Claude Console 分组账号:') + groupConsoleAccounts.forEach((acc) => { + console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`) + }) } - } catch (error) { - console.error('❌ 错误:', error); - console.error(error.stack); + console.error('❌ 错误:', error) + console.error(error.stack) } finally { - process.exit(0); + process.exit(0) } } -testDedicatedAccounts(); \ No newline at end of file +testDedicatedAccounts() diff --git a/scripts/test-gemini-refresh.js b/scripts/test-gemini-refresh.js index 2580adce..6de3ee78 100644 --- a/scripts/test-gemini-refresh.js +++ b/scripts/test-gemini-refresh.js @@ -4,141 +4,142 @@ * 测试 Gemini token 刷新功能 */ -const path = require('path'); -const dotenv = require('dotenv'); +const path = require('path') +const dotenv = require('dotenv') // 加载环境变量 -dotenv.config({ path: path.join(__dirname, '..', '.env') }); +dotenv.config({ path: path.join(__dirname, '..', '.env') }) -const redis = require('../src/models/redis'); -const geminiAccountService = require('../src/services/geminiAccountService'); -const logger = require('../src/utils/logger'); -const crypto = require('crypto'); -const config = require('../config/config'); +const redis = require('../src/models/redis') +const geminiAccountService = require('../src/services/geminiAccountService') +const crypto = require('crypto') +const config = require('../config/config') // 加密相关常量(与 geminiAccountService 保持一致) -const ALGORITHM = 'aes-256-cbc'; -const ENCRYPTION_SALT = 'gemini-account-salt'; // 注意:是 'gemini-account-salt' 不是其他值! +const ALGORITHM = 'aes-256-cbc' +const ENCRYPTION_SALT = 'gemini-account-salt' // 注意:是 'gemini-account-salt' 不是其他值! // 生成加密密钥 function generateEncryptionKey() { - return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32); + return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32) } // 解密函数(用于调试) function debugDecrypt(text) { - if (!text) return { success: false, error: 'Empty text' }; + if (!text) { + return { success: false, error: 'Empty text' } + } try { - const key = generateEncryptionKey(); - const ivHex = text.substring(0, 32); - const encryptedHex = text.substring(33); - - const iv = Buffer.from(ivHex, 'hex'); - const encryptedText = Buffer.from(encryptedHex, 'hex'); - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); - let decrypted = decipher.update(encryptedText); - decrypted = Buffer.concat([decrypted, decipher.final()]); - return { success: true, value: decrypted.toString() }; + const key = generateEncryptionKey() + const ivHex = text.substring(0, 32) + const encryptedHex = text.substring(33) + + const iv = Buffer.from(ivHex, 'hex') + const encryptedText = Buffer.from(encryptedHex, 'hex') + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + let decrypted = decipher.update(encryptedText) + decrypted = Buffer.concat([decrypted, decipher.final()]) + return { success: true, value: decrypted.toString() } } catch (error) { - return { success: false, error: error.message }; + return { success: false, error: error.message } } } async function testGeminiTokenRefresh() { try { - console.log('🚀 开始测试 Gemini token 刷新功能...\n'); - + console.log('🚀 开始测试 Gemini token 刷新功能...\n') + // 显示配置信息 - console.log('📋 配置信息:'); - console.log(` 加密密钥: ${config.security.encryptionKey}`); - console.log(` 加密盐值: ${ENCRYPTION_SALT}`); - console.log(); - + console.log('📋 配置信息:') + console.log(` 加密密钥: ${config.security.encryptionKey}`) + console.log(` 加密盐值: ${ENCRYPTION_SALT}`) + console.log() + // 1. 连接 Redis - console.log('📡 连接 Redis...'); - await redis.connect(); - console.log('✅ Redis 连接成功\n'); - + console.log('📡 连接 Redis...') + await redis.connect() + console.log('✅ Redis 连接成功\n') + // 2. 获取所有 Gemini 账户 - console.log('🔍 获取 Gemini 账户列表...'); - const accounts = await geminiAccountService.getAllAccounts(); - const geminiAccounts = accounts.filter(acc => acc.platform === 'gemini'); - + console.log('🔍 获取 Gemini 账户列表...') + const accounts = await geminiAccountService.getAllAccounts() + const geminiAccounts = accounts.filter((acc) => acc.platform === 'gemini') + if (geminiAccounts.length === 0) { - console.log('❌ 没有找到 Gemini 账户'); - process.exit(1); + console.log('❌ 没有找到 Gemini 账户') + process.exit(1) } - - console.log(`✅ 找到 ${geminiAccounts.length} 个 Gemini 账户\n`); - + + console.log(`✅ 找到 ${geminiAccounts.length} 个 Gemini 账户\n`) + // 3. 测试每个账户的 token 刷新 for (const account of geminiAccounts) { - console.log(`\n📋 测试账户: ${account.name} (${account.id})`); - console.log(` 状态: ${account.status}`); - + console.log(`\n📋 测试账户: ${account.name} (${account.id})`) + console.log(` 状态: ${account.status}`) + try { // 获取原始账户数据(用于调试) - const client = redis.getClient(); - const rawData = await client.hgetall(`gemini_account:${account.id}`); - - console.log(' 📊 原始数据检查:'); - console.log(` refreshToken 存在: ${rawData.refreshToken ? '是' : '否'}`); + const client = redis.getClient() + const rawData = await client.hgetall(`gemini_account:${account.id}`) + + console.log(' 📊 原始数据检查:') + console.log(` refreshToken 存在: ${rawData.refreshToken ? '是' : '否'}`) if (rawData.refreshToken) { - console.log(` refreshToken 长度: ${rawData.refreshToken.length}`); - console.log(` refreshToken 前50字符: ${rawData.refreshToken.substring(0, 50)}...`); - + console.log(` refreshToken 长度: ${rawData.refreshToken.length}`) + console.log(` refreshToken 前50字符: ${rawData.refreshToken.substring(0, 50)}...`) + // 尝试手动解密 - const decryptResult = debugDecrypt(rawData.refreshToken); + const decryptResult = debugDecrypt(rawData.refreshToken) if (decryptResult.success) { - console.log(` ✅ 手动解密成功`); - console.log(` 解密后前20字符: ${decryptResult.value.substring(0, 20)}...`); + console.log(' ✅ 手动解密成功') + console.log(` 解密后前20字符: ${decryptResult.value.substring(0, 20)}...`) } else { - console.log(` ❌ 手动解密失败: ${decryptResult.error}`); + console.log(` ❌ 手动解密失败: ${decryptResult.error}`) } } - + // 获取完整账户信息(包括解密的 token) - const fullAccount = await geminiAccountService.getAccount(account.id); - + const fullAccount = await geminiAccountService.getAccount(account.id) + if (!fullAccount.refreshToken) { - console.log(' ⚠️ 跳过:该账户无 refresh token\n'); - continue; + console.log(' ⚠️ 跳过:该账户无 refresh token\n') + continue } - - console.log(` ✅ 找到 refresh token`); - console.log(` 📝 解密后的 refresh token 前20字符: ${fullAccount.refreshToken.substring(0, 20)}...`); - - console.log(' 🔄 开始刷新 token...'); - const startTime = Date.now(); - + + console.log(' ✅ 找到 refresh token') + console.log( + ` 📝 解密后的 refresh token 前20字符: ${fullAccount.refreshToken.substring(0, 20)}...` + ) + + console.log(' 🔄 开始刷新 token...') + const startTime = Date.now() + // 执行 token 刷新 - const newTokens = await geminiAccountService.refreshAccountToken(account.id); - - const duration = Date.now() - startTime; - console.log(` ✅ Token 刷新成功!耗时: ${duration}ms`); - console.log(` 📅 新的过期时间: ${new Date(newTokens.expiry_date).toLocaleString()}`); - console.log(` 🔑 Access Token: ${newTokens.access_token.substring(0, 20)}...`); - + const newTokens = await geminiAccountService.refreshAccountToken(account.id) + + const duration = Date.now() - startTime + console.log(` ✅ Token 刷新成功!耗时: ${duration}ms`) + console.log(` 📅 新的过期时间: ${new Date(newTokens.expiry_date).toLocaleString()}`) + console.log(` 🔑 Access Token: ${newTokens.access_token.substring(0, 20)}...`) + // 验证账户状态已更新 - const updatedAccount = await geminiAccountService.getAccount(account.id); - console.log(` 📊 账户状态: ${updatedAccount.status}`); - + const updatedAccount = await geminiAccountService.getAccount(account.id) + console.log(` 📊 账户状态: ${updatedAccount.status}`) } catch (error) { - console.log(` ❌ Token 刷新失败: ${error.message}`); - console.log(` 🔍 错误详情:`, error); + console.log(` ❌ Token 刷新失败: ${error.message}`) + console.log(' 🔍 错误详情:', error) } } - - console.log('\n✅ 测试完成!'); - + + console.log('\n✅ 测试完成!') } catch (error) { - console.error('❌ 测试失败:', error); + console.error('❌ 测试失败:', error) } finally { // 断开 Redis 连接 - await redis.disconnect(); - process.exit(0); + await redis.disconnect() + process.exit(0) } } // 运行测试 -testGeminiTokenRefresh(); \ No newline at end of file +testGeminiTokenRefresh() diff --git a/scripts/test-group-scheduling.js b/scripts/test-group-scheduling.js index eff94ec2..4312ec65 100644 --- a/scripts/test-group-scheduling.js +++ b/scripts/test-group-scheduling.js @@ -3,26 +3,25 @@ * 用于测试账户分组管理和调度逻辑的正确性 */ -require('dotenv').config(); -const { v4: uuidv4 } = require('uuid'); -const redis = require('../src/models/redis'); -const accountGroupService = require('../src/services/accountGroupService'); -const claudeAccountService = require('../src/services/claudeAccountService'); -const claudeConsoleAccountService = require('../src/services/claudeConsoleAccountService'); -const apiKeyService = require('../src/services/apiKeyService'); -const unifiedClaudeScheduler = require('../src/services/unifiedClaudeScheduler'); -const logger = require('../src/utils/logger'); +require('dotenv').config() +const { v4: uuidv4 } = require('uuid') +const redis = require('../src/models/redis') +const accountGroupService = require('../src/services/accountGroupService') +const claudeAccountService = require('../src/services/claudeAccountService') +const claudeConsoleAccountService = require('../src/services/claudeConsoleAccountService') +const apiKeyService = require('../src/services/apiKeyService') +const unifiedClaudeScheduler = require('../src/services/unifiedClaudeScheduler') // 测试配置 -const TEST_PREFIX = 'test_group_'; -const CLEANUP_ON_FINISH = true; // 测试完成后是否清理数据 +const TEST_PREFIX = 'test_group_' +const CLEANUP_ON_FINISH = true // 测试完成后是否清理数据 // 测试数据存储 const testData = { groups: [], accounts: [], apiKeys: [] -}; +} // 颜色输出 const colors = { @@ -31,68 +30,69 @@ const colors = { yellow: '\x1b[33m', blue: '\x1b[34m', reset: '\x1b[0m' -}; +} function log(message, type = 'info') { - const color = { - success: colors.green, - error: colors.red, - warning: colors.yellow, - info: colors.blue - }[type] || colors.reset; - - console.log(`${color}${message}${colors.reset}`); + const color = + { + success: colors.green, + error: colors.red, + warning: colors.yellow, + info: colors.blue + }[type] || colors.reset + + console.log(`${color}${message}${colors.reset}`) } async function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)) } // 清理测试数据 async function cleanup() { - log('\n🧹 清理测试数据...', 'info'); - + log('\n🧹 清理测试数据...', 'info') + // 删除测试API Keys for (const apiKey of testData.apiKeys) { try { - await apiKeyService.deleteApiKey(apiKey.id); - log(`✅ 删除测试API Key: ${apiKey.name}`, 'success'); + await apiKeyService.deleteApiKey(apiKey.id) + log(`✅ 删除测试API Key: ${apiKey.name}`, 'success') } catch (error) { - log(`❌ 删除API Key失败: ${error.message}`, 'error'); + log(`❌ 删除API Key失败: ${error.message}`, 'error') } } - + // 删除测试账户 for (const account of testData.accounts) { try { if (account.type === 'claude') { - await claudeAccountService.deleteAccount(account.id); + await claudeAccountService.deleteAccount(account.id) } else if (account.type === 'claude-console') { - await claudeConsoleAccountService.deleteAccount(account.id); + await claudeConsoleAccountService.deleteAccount(account.id) } - log(`✅ 删除测试账户: ${account.name}`, 'success'); + log(`✅ 删除测试账户: ${account.name}`, 'success') } catch (error) { - log(`❌ 删除账户失败: ${error.message}`, 'error'); + log(`❌ 删除账户失败: ${error.message}`, 'error') } } - + // 删除测试分组 for (const group of testData.groups) { try { - await accountGroupService.deleteGroup(group.id); - log(`✅ 删除测试分组: ${group.name}`, 'success'); + await accountGroupService.deleteGroup(group.id) + log(`✅ 删除测试分组: ${group.name}`, 'success') } catch (error) { // 可能因为还有成员而删除失败,先移除所有成员 if (error.message.includes('分组内还有账户')) { - const members = await accountGroupService.getGroupMembers(group.id); + const members = await accountGroupService.getGroupMembers(group.id) for (const memberId of members) { - await accountGroupService.removeAccountFromGroup(memberId, group.id); + await accountGroupService.removeAccountFromGroup(memberId, group.id) } // 重试删除 - await accountGroupService.deleteGroup(group.id); - log(`✅ 删除测试分组: ${group.name} (清空成员后)`, 'success'); + await accountGroupService.deleteGroup(group.id) + log(`✅ 删除测试分组: ${group.name} (清空成员后)`, 'success') } else { - log(`❌ 删除分组失败: ${error.message}`, 'error'); + log(`❌ 删除分组失败: ${error.message}`, 'error') } } } @@ -100,446 +100,449 @@ async function cleanup() { // 测试1: 创建分组 async function test1_createGroups() { - log('\n📝 测试1: 创建账户分组', 'info'); - + log('\n📝 测试1: 创建账户分组', 'info') + try { // 创建Claude分组 const claudeGroup = await accountGroupService.createGroup({ - name: TEST_PREFIX + 'Claude组', + name: `${TEST_PREFIX}Claude组`, platform: 'claude', description: '测试用Claude账户分组' - }); - testData.groups.push(claudeGroup); - log(`✅ 创建Claude分组成功: ${claudeGroup.name} (ID: ${claudeGroup.id})`, 'success'); - + }) + testData.groups.push(claudeGroup) + log(`✅ 创建Claude分组成功: ${claudeGroup.name} (ID: ${claudeGroup.id})`, 'success') + // 创建Gemini分组 const geminiGroup = await accountGroupService.createGroup({ - name: TEST_PREFIX + 'Gemini组', + name: `${TEST_PREFIX}Gemini组`, platform: 'gemini', description: '测试用Gemini账户分组' - }); - testData.groups.push(geminiGroup); - log(`✅ 创建Gemini分组成功: ${geminiGroup.name} (ID: ${geminiGroup.id})`, 'success'); - + }) + testData.groups.push(geminiGroup) + log(`✅ 创建Gemini分组成功: ${geminiGroup.name} (ID: ${geminiGroup.id})`, 'success') + // 验证分组信息 - const allGroups = await accountGroupService.getAllGroups(); - const testGroups = allGroups.filter(g => g.name.startsWith(TEST_PREFIX)); - + const allGroups = await accountGroupService.getAllGroups() + const testGroups = allGroups.filter((g) => g.name.startsWith(TEST_PREFIX)) + if (testGroups.length === 2) { - log(`✅ 分组创建验证通过,共创建 ${testGroups.length} 个测试分组`, 'success'); + log(`✅ 分组创建验证通过,共创建 ${testGroups.length} 个测试分组`, 'success') } else { - throw new Error(`分组数量不正确,期望2个,实际${testGroups.length}个`); + throw new Error(`分组数量不正确,期望2个,实际${testGroups.length}个`) } - } catch (error) { - log(`❌ 测试1失败: ${error.message}`, 'error'); - throw error; + log(`❌ 测试1失败: ${error.message}`, 'error') + throw error } } // 测试2: 创建账户并添加到分组 async function test2_createAccountsAndAddToGroup() { - log('\n📝 测试2: 创建账户并添加到分组', 'info'); - + log('\n📝 测试2: 创建账户并添加到分组', 'info') + try { - const claudeGroup = testData.groups.find(g => g.platform === 'claude'); - + const claudeGroup = testData.groups.find((g) => g.platform === 'claude') + // 创建Claude OAuth账户 const claudeAccount1 = await claudeAccountService.createAccount({ - name: TEST_PREFIX + 'Claude账户1', + name: `${TEST_PREFIX}Claude账户1`, email: 'test1@example.com', refreshToken: 'test_refresh_token_1', accountType: 'group' - }); - testData.accounts.push({ ...claudeAccount1, type: 'claude' }); - log(`✅ 创建Claude OAuth账户1成功: ${claudeAccount1.name}`, 'success'); - + }) + testData.accounts.push({ ...claudeAccount1, type: 'claude' }) + log(`✅ 创建Claude OAuth账户1成功: ${claudeAccount1.name}`, 'success') + const claudeAccount2 = await claudeAccountService.createAccount({ - name: TEST_PREFIX + 'Claude账户2', + name: `${TEST_PREFIX}Claude账户2`, email: 'test2@example.com', refreshToken: 'test_refresh_token_2', accountType: 'group' - }); - testData.accounts.push({ ...claudeAccount2, type: 'claude' }); - log(`✅ 创建Claude OAuth账户2成功: ${claudeAccount2.name}`, 'success'); - + }) + testData.accounts.push({ ...claudeAccount2, type: 'claude' }) + log(`✅ 创建Claude OAuth账户2成功: ${claudeAccount2.name}`, 'success') + // 创建Claude Console账户 const consoleAccount = await claudeConsoleAccountService.createAccount({ - name: TEST_PREFIX + 'Console账户', + name: `${TEST_PREFIX}Console账户`, apiUrl: 'https://api.example.com', apiKey: 'test_api_key', accountType: 'group' - }); - testData.accounts.push({ ...consoleAccount, type: 'claude-console' }); - log(`✅ 创建Claude Console账户成功: ${consoleAccount.name}`, 'success'); - + }) + testData.accounts.push({ ...consoleAccount, type: 'claude-console' }) + log(`✅ 创建Claude Console账户成功: ${consoleAccount.name}`, 'success') + // 添加账户到分组 - await accountGroupService.addAccountToGroup(claudeAccount1.id, claudeGroup.id, 'claude'); - log(`✅ 添加账户1到分组成功`, 'success'); - - await accountGroupService.addAccountToGroup(claudeAccount2.id, claudeGroup.id, 'claude'); - log(`✅ 添加账户2到分组成功`, 'success'); - - await accountGroupService.addAccountToGroup(consoleAccount.id, claudeGroup.id, 'claude'); - log(`✅ 添加Console账户到分组成功`, 'success'); - + await accountGroupService.addAccountToGroup(claudeAccount1.id, claudeGroup.id, 'claude') + log('✅ 添加账户1到分组成功', 'success') + + await accountGroupService.addAccountToGroup(claudeAccount2.id, claudeGroup.id, 'claude') + log('✅ 添加账户2到分组成功', 'success') + + await accountGroupService.addAccountToGroup(consoleAccount.id, claudeGroup.id, 'claude') + log('✅ 添加Console账户到分组成功', 'success') + // 验证分组成员 - const members = await accountGroupService.getGroupMembers(claudeGroup.id); + const members = await accountGroupService.getGroupMembers(claudeGroup.id) if (members.length === 3) { - log(`✅ 分组成员验证通过,共有 ${members.length} 个成员`, 'success'); + log(`✅ 分组成员验证通过,共有 ${members.length} 个成员`, 'success') } else { - throw new Error(`分组成员数量不正确,期望3个,实际${members.length}个`); + throw new Error(`分组成员数量不正确,期望3个,实际${members.length}个`) } - } catch (error) { - log(`❌ 测试2失败: ${error.message}`, 'error'); - throw error; + log(`❌ 测试2失败: ${error.message}`, 'error') + throw error } } // 测试3: 平台一致性验证 async function test3_platformConsistency() { - log('\n📝 测试3: 平台一致性验证', 'info'); - + log('\n📝 测试3: 平台一致性验证', 'info') + try { - const claudeGroup = testData.groups.find(g => g.platform === 'claude'); - const geminiGroup = testData.groups.find(g => g.platform === 'gemini'); - + const geminiGroup = testData.groups.find((g) => g.platform === 'gemini') + // 尝试将Claude账户添加到Gemini分组(应该失败) - const claudeAccount = testData.accounts.find(a => a.type === 'claude'); - + const claudeAccount = testData.accounts.find((a) => a.type === 'claude') + try { - await accountGroupService.addAccountToGroup(claudeAccount.id, geminiGroup.id, 'claude'); - throw new Error('平台验证失败:Claude账户不应该能添加到Gemini分组'); + await accountGroupService.addAccountToGroup(claudeAccount.id, geminiGroup.id, 'claude') + throw new Error('平台验证失败:Claude账户不应该能添加到Gemini分组') } catch (error) { if (error.message.includes('平台与分组平台不匹配')) { - log(`✅ 平台一致性验证通过:${error.message}`, 'success'); + log(`✅ 平台一致性验证通过:${error.message}`, 'success') } else { - throw error; + throw error } } - } catch (error) { - log(`❌ 测试3失败: ${error.message}`, 'error'); - throw error; + log(`❌ 测试3失败: ${error.message}`, 'error') + throw error } } // 测试4: API Key绑定分组 async function test4_apiKeyBindGroup() { - log('\n📝 测试4: API Key绑定分组', 'info'); - + log('\n📝 测试4: API Key绑定分组', 'info') + try { - const claudeGroup = testData.groups.find(g => g.platform === 'claude'); - + const claudeGroup = testData.groups.find((g) => g.platform === 'claude') + // 创建绑定到分组的API Key const apiKey = await apiKeyService.generateApiKey({ - name: TEST_PREFIX + 'API Key', + name: `${TEST_PREFIX}API Key`, description: '测试分组调度的API Key', claudeAccountId: `group:${claudeGroup.id}`, permissions: 'claude' - }); - testData.apiKeys.push(apiKey); - log(`✅ 创建API Key成功: ${apiKey.name} (绑定到分组: ${claudeGroup.name})`, 'success'); - + }) + testData.apiKeys.push(apiKey) + log(`✅ 创建API Key成功: ${apiKey.name} (绑定到分组: ${claudeGroup.name})`, 'success') + // 验证API Key信息 - const keyInfo = await redis.getApiKey(apiKey.id); + const keyInfo = await redis.getApiKey(apiKey.id) if (keyInfo && keyInfo.claudeAccountId === `group:${claudeGroup.id}`) { - log(`✅ API Key分组绑定验证通过`, 'success'); + log('✅ API Key分组绑定验证通过', 'success') } else { - throw new Error('API Key分组绑定信息不正确'); + throw new Error('API Key分组绑定信息不正确') } - } catch (error) { - log(`❌ 测试4失败: ${error.message}`, 'error'); - throw error; + log(`❌ 测试4失败: ${error.message}`, 'error') + throw error } } // 测试5: 分组调度负载均衡 async function test5_groupSchedulingLoadBalance() { - log('\n📝 测试5: 分组调度负载均衡', 'info'); - + log('\n📝 测试5: 分组调度负载均衡', 'info') + try { - const claudeGroup = testData.groups.find(g => g.platform === 'claude'); - const apiKey = testData.apiKeys[0]; - + const apiKey = testData.apiKeys[0] + // 记录每个账户被选中的次数 - const selectionCount = {}; - const totalSelections = 30; - + const selectionCount = {} + const totalSelections = 30 + for (let i = 0; i < totalSelections; i++) { // 模拟不同的会话 - const sessionHash = uuidv4(); - - const result = await unifiedClaudeScheduler.selectAccountForApiKey({ - id: apiKey.id, - claudeAccountId: apiKey.claudeAccountId, - name: apiKey.name - }, sessionHash); - + const sessionHash = uuidv4() + + const result = await unifiedClaudeScheduler.selectAccountForApiKey( + { + id: apiKey.id, + claudeAccountId: apiKey.claudeAccountId, + name: apiKey.name + }, + sessionHash + ) + if (!selectionCount[result.accountId]) { - selectionCount[result.accountId] = 0; + selectionCount[result.accountId] = 0 } - selectionCount[result.accountId]++; - + selectionCount[result.accountId]++ + // 短暂延迟,模拟真实请求间隔 - await sleep(50); + await sleep(50) } - + // 分析选择分布 - log(`\n📊 负载均衡分布统计 (共${totalSelections}次选择):`, 'info'); - const accounts = Object.keys(selectionCount); - + log(`\n📊 负载均衡分布统计 (共${totalSelections}次选择):`, 'info') + const accounts = Object.keys(selectionCount) + for (const accountId of accounts) { - const count = selectionCount[accountId]; - const percentage = ((count / totalSelections) * 100).toFixed(1); - const accountInfo = testData.accounts.find(a => a.id === accountId); - log(` ${accountInfo.name}: ${count}次 (${percentage}%)`, 'info'); + const count = selectionCount[accountId] + const percentage = ((count / totalSelections) * 100).toFixed(1) + const accountInfo = testData.accounts.find((a) => a.id === accountId) + log(` ${accountInfo.name}: ${count}次 (${percentage}%)`, 'info') } - + // 验证是否实现了负载均衡 - const counts = Object.values(selectionCount); - const avgCount = totalSelections / accounts.length; - const variance = counts.reduce((sum, count) => sum + Math.pow(count - avgCount, 2), 0) / counts.length; - const stdDev = Math.sqrt(variance); - - log(`\n 平均选择次数: ${avgCount.toFixed(1)}`, 'info'); - log(` 标准差: ${stdDev.toFixed(1)}`, 'info'); - + const counts = Object.values(selectionCount) + const avgCount = totalSelections / accounts.length + const variance = + counts.reduce((sum, count) => sum + Math.pow(count - avgCount, 2), 0) / counts.length + const stdDev = Math.sqrt(variance) + + log(`\n 平均选择次数: ${avgCount.toFixed(1)}`, 'info') + log(` 标准差: ${stdDev.toFixed(1)}`, 'info') + // 如果标准差小于平均值的50%,认为负载均衡效果良好 if (stdDev < avgCount * 0.5) { - log(`✅ 负载均衡验证通过,分布相对均匀`, 'success'); + log('✅ 负载均衡验证通过,分布相对均匀', 'success') } else { - log(`⚠️ 负载分布不够均匀,但这可能是正常的随机波动`, 'warning'); + log('⚠️ 负载分布不够均匀,但这可能是正常的随机波动', 'warning') } - } catch (error) { - log(`❌ 测试5失败: ${error.message}`, 'error'); - throw error; + log(`❌ 测试5失败: ${error.message}`, 'error') + throw error } } // 测试6: 会话粘性测试 async function test6_stickySession() { - log('\n📝 测试6: 会话粘性(Sticky Session)测试', 'info'); - + log('\n📝 测试6: 会话粘性(Sticky Session)测试', 'info') + try { - const apiKey = testData.apiKeys[0]; - const sessionHash = 'test_session_' + uuidv4(); - + const apiKey = testData.apiKeys[0] + const sessionHash = `test_session_${uuidv4()}` + // 第一次选择 - const firstSelection = await unifiedClaudeScheduler.selectAccountForApiKey({ - id: apiKey.id, - claudeAccountId: apiKey.claudeAccountId, - name: apiKey.name - }, sessionHash); - - log(` 首次选择账户: ${firstSelection.accountId}`, 'info'); - - // 使用相同的sessionHash多次请求 - let consistentCount = 0; - const testCount = 10; - - for (let i = 0; i < testCount; i++) { - const selection = await unifiedClaudeScheduler.selectAccountForApiKey({ + const firstSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + { id: apiKey.id, claudeAccountId: apiKey.claudeAccountId, name: apiKey.name - }, sessionHash); - + }, + sessionHash + ) + + log(` 首次选择账户: ${firstSelection.accountId}`, 'info') + + // 使用相同的sessionHash多次请求 + let consistentCount = 0 + const testCount = 10 + + for (let i = 0; i < testCount; i++) { + const selection = await unifiedClaudeScheduler.selectAccountForApiKey( + { + id: apiKey.id, + claudeAccountId: apiKey.claudeAccountId, + name: apiKey.name + }, + sessionHash + ) + if (selection.accountId === firstSelection.accountId) { - consistentCount++; + consistentCount++ } - - await sleep(100); + + await sleep(100) } - - log(` 会话一致性: ${consistentCount}/${testCount} 次选择了相同账户`, 'info'); - + + log(` 会话一致性: ${consistentCount}/${testCount} 次选择了相同账户`, 'info') + if (consistentCount === testCount) { - log(`✅ 会话粘性验证通过,同一会话始终选择相同账户`, 'success'); + log('✅ 会话粘性验证通过,同一会话始终选择相同账户', 'success') } else { - throw new Error(`会话粘性失败,只有${consistentCount}/${testCount}次选择了相同账户`); + throw new Error(`会话粘性失败,只有${consistentCount}/${testCount}次选择了相同账户`) } - } catch (error) { - log(`❌ 测试6失败: ${error.message}`, 'error'); - throw error; + log(`❌ 测试6失败: ${error.message}`, 'error') + throw error } } // 测试7: 账户可用性检查 async function test7_accountAvailability() { - log('\n📝 测试7: 账户可用性检查', 'info'); - + log('\n📝 测试7: 账户可用性检查', 'info') + try { - const apiKey = testData.apiKeys[0]; - const accounts = testData.accounts.filter(a => a.type === 'claude' || a.type === 'claude-console'); - + const apiKey = testData.apiKeys[0] + const accounts = testData.accounts.filter( + (a) => a.type === 'claude' || a.type === 'claude-console' + ) + // 禁用第一个账户 - const firstAccount = accounts[0]; + const firstAccount = accounts[0] if (firstAccount.type === 'claude') { - await claudeAccountService.updateAccount(firstAccount.id, { isActive: false }); + await claudeAccountService.updateAccount(firstAccount.id, { isActive: false }) } else { - await claudeConsoleAccountService.updateAccount(firstAccount.id, { isActive: false }); + await claudeConsoleAccountService.updateAccount(firstAccount.id, { isActive: false }) } - log(` 已禁用账户: ${firstAccount.name}`, 'info'); - + log(` 已禁用账户: ${firstAccount.name}`, 'info') + // 多次选择,验证不会选择到禁用的账户 - const selectionResults = []; + const selectionResults = [] for (let i = 0; i < 20; i++) { - const sessionHash = uuidv4(); // 每次使用新会话 - const result = await unifiedClaudeScheduler.selectAccountForApiKey({ - id: apiKey.id, - claudeAccountId: apiKey.claudeAccountId, - name: apiKey.name - }, sessionHash); - - selectionResults.push(result.accountId); + const sessionHash = uuidv4() // 每次使用新会话 + const result = await unifiedClaudeScheduler.selectAccountForApiKey( + { + id: apiKey.id, + claudeAccountId: apiKey.claudeAccountId, + name: apiKey.name + }, + sessionHash + ) + + selectionResults.push(result.accountId) } - + // 检查是否选择了禁用的账户 - const selectedDisabled = selectionResults.includes(firstAccount.id); - + const selectedDisabled = selectionResults.includes(firstAccount.id) + if (!selectedDisabled) { - log(`✅ 账户可用性验证通过,未选择禁用的账户`, 'success'); + log('✅ 账户可用性验证通过,未选择禁用的账户', 'success') } else { - throw new Error('错误:选择了已禁用的账户'); + throw new Error('错误:选择了已禁用的账户') } - + // 重新启用账户 if (firstAccount.type === 'claude') { - await claudeAccountService.updateAccount(firstAccount.id, { isActive: true }); + await claudeAccountService.updateAccount(firstAccount.id, { isActive: true }) } else { - await claudeConsoleAccountService.updateAccount(firstAccount.id, { isActive: true }); + await claudeConsoleAccountService.updateAccount(firstAccount.id, { isActive: true }) } - } catch (error) { - log(`❌ 测试7失败: ${error.message}`, 'error'); - throw error; + log(`❌ 测试7失败: ${error.message}`, 'error') + throw error } } // 测试8: 分组成员管理 async function test8_groupMemberManagement() { - log('\n📝 测试8: 分组成员管理', 'info'); - + log('\n📝 测试8: 分组成员管理', 'info') + try { - const claudeGroup = testData.groups.find(g => g.platform === 'claude'); - const account = testData.accounts.find(a => a.type === 'claude'); - + const claudeGroup = testData.groups.find((g) => g.platform === 'claude') + const account = testData.accounts.find((a) => a.type === 'claude') + // 获取账户所属分组 - const accountGroup = await accountGroupService.getAccountGroup(account.id); + const accountGroup = await accountGroupService.getAccountGroup(account.id) if (accountGroup && accountGroup.id === claudeGroup.id) { - log(`✅ 账户分组查询验证通过`, 'success'); + log('✅ 账户分组查询验证通过', 'success') } else { - throw new Error('账户分组查询结果不正确'); + throw new Error('账户分组查询结果不正确') } - + // 从分组移除账户 - await accountGroupService.removeAccountFromGroup(account.id, claudeGroup.id); - log(` 从分组移除账户: ${account.name}`, 'info'); - + await accountGroupService.removeAccountFromGroup(account.id, claudeGroup.id) + log(` 从分组移除账户: ${account.name}`, 'info') + // 验证账户已不在分组中 - const membersAfterRemove = await accountGroupService.getGroupMembers(claudeGroup.id); + const membersAfterRemove = await accountGroupService.getGroupMembers(claudeGroup.id) if (!membersAfterRemove.includes(account.id)) { - log(`✅ 账户移除验证通过`, 'success'); + log('✅ 账户移除验证通过', 'success') } else { - throw new Error('账户移除失败'); + throw new Error('账户移除失败') } - + // 重新添加账户 - await accountGroupService.addAccountToGroup(account.id, claudeGroup.id, 'claude'); - log(` 重新添加账户到分组`, 'info'); - + await accountGroupService.addAccountToGroup(account.id, claudeGroup.id, 'claude') + log(' 重新添加账户到分组', 'info') } catch (error) { - log(`❌ 测试8失败: ${error.message}`, 'error'); - throw error; + log(`❌ 测试8失败: ${error.message}`, 'error') + throw error } } // 测试9: 空分组处理 async function test9_emptyGroupHandling() { - log('\n📝 测试9: 空分组处理', 'info'); - + log('\n📝 测试9: 空分组处理', 'info') + try { // 创建一个空分组 const emptyGroup = await accountGroupService.createGroup({ - name: TEST_PREFIX + '空分组', + name: `${TEST_PREFIX}空分组`, platform: 'claude', description: '测试空分组' - }); - testData.groups.push(emptyGroup); - + }) + testData.groups.push(emptyGroup) + // 创建绑定到空分组的API Key const apiKey = await apiKeyService.generateApiKey({ - name: TEST_PREFIX + '空分组API Key', + name: `${TEST_PREFIX}空分组API Key`, claudeAccountId: `group:${emptyGroup.id}`, permissions: 'claude' - }); - testData.apiKeys.push(apiKey); - + }) + testData.apiKeys.push(apiKey) + // 尝试从空分组选择账户(应该失败) try { await unifiedClaudeScheduler.selectAccountForApiKey({ id: apiKey.id, claudeAccountId: apiKey.claudeAccountId, name: apiKey.name - }); - throw new Error('空分组选择账户应该失败'); + }) + throw new Error('空分组选择账户应该失败') } catch (error) { if (error.message.includes('has no members')) { - log(`✅ 空分组处理验证通过:${error.message}`, 'success'); + log(`✅ 空分组处理验证通过:${error.message}`, 'success') } else { - throw error; + throw error } } - } catch (error) { - log(`❌ 测试9失败: ${error.message}`, 'error'); - throw error; + log(`❌ 测试9失败: ${error.message}`, 'error') + throw error } } // 主测试函数 async function runTests() { - log('\n🚀 开始分组调度功能测试\n', 'info'); - + log('\n🚀 开始分组调度功能测试\n', 'info') + try { // 连接Redis - await redis.connect(); - log('✅ Redis连接成功', 'success'); - + await redis.connect() + log('✅ Redis连接成功', 'success') + // 执行测试 - await test1_createGroups(); - await test2_createAccountsAndAddToGroup(); - await test3_platformConsistency(); - await test4_apiKeyBindGroup(); - await test5_groupSchedulingLoadBalance(); - await test6_stickySession(); - await test7_accountAvailability(); - await test8_groupMemberManagement(); - await test9_emptyGroupHandling(); - - log('\n🎉 所有测试通过!分组调度功能工作正常', 'success'); - + await test1_createGroups() + await test2_createAccountsAndAddToGroup() + await test3_platformConsistency() + await test4_apiKeyBindGroup() + await test5_groupSchedulingLoadBalance() + await test6_stickySession() + await test7_accountAvailability() + await test8_groupMemberManagement() + await test9_emptyGroupHandling() + + log('\n🎉 所有测试通过!分组调度功能工作正常', 'success') } catch (error) { - log(`\n❌ 测试失败: ${error.message}`, 'error'); - console.error(error); + log(`\n❌ 测试失败: ${error.message}`, 'error') + console.error(error) } finally { // 清理测试数据 if (CLEANUP_ON_FINISH) { - await cleanup(); + await cleanup() } else { - log('\n⚠️ 测试数据未清理,请手动清理', 'warning'); + log('\n⚠️ 测试数据未清理,请手动清理', 'warning') } - + // 关闭Redis连接 - await redis.disconnect(); - process.exit(0); + await redis.disconnect() + process.exit(0) } } // 运行测试 -runTests(); \ No newline at end of file +runTests() diff --git a/scripts/test-model-mapping.js b/scripts/test-model-mapping.js index f6e33905..7719f9d5 100644 --- a/scripts/test-model-mapping.js +++ b/scripts/test-model-mapping.js @@ -1,47 +1,47 @@ #!/usr/bin/env node -const bedrockRelayService = require('../src/services/bedrockRelayService'); +const bedrockRelayService = require('../src/services/bedrockRelayService') function testModelMapping() { - console.log('🧪 测试模型映射功能...'); - + console.log('🧪 测试模型映射功能...') + // 测试用例 const testCases = [ // 标准Claude模型名 'claude-3-5-haiku-20241022', - 'claude-3-5-sonnet-20241022', + 'claude-3-5-sonnet-20241022', 'claude-3-5-sonnet', 'claude-3-5-haiku', 'claude-sonnet-4', 'claude-opus-4-1', 'claude-3-7-sonnet', - + // 已经是Bedrock格式的 'us.anthropic.claude-sonnet-4-20250514-v1:0', 'anthropic.claude-3-5-haiku-20241022-v1:0', - + // 未知模型 'unknown-model' - ]; + ] - console.log('\n📋 模型映射测试结果:'); - testCases.forEach(testModel => { - const mappedModel = bedrockRelayService._mapToBedrockModel(testModel); - const isChanged = mappedModel !== testModel; - const status = isChanged ? '🔄' : '✅'; - - console.log(`${status} ${testModel}`); + console.log('\n📋 模型映射测试结果:') + testCases.forEach((testModel) => { + const mappedModel = bedrockRelayService._mapToBedrockModel(testModel) + const isChanged = mappedModel !== testModel + const status = isChanged ? '🔄' : '✅' + + console.log(`${status} ${testModel}`) if (isChanged) { - console.log(` → ${mappedModel}`); + console.log(` → ${mappedModel}`) } - }); + }) - console.log('\n✅ 模型映射测试完成'); + console.log('\n✅ 模型映射测试完成') } // 如果直接运行此脚本 if (require.main === module) { - testModelMapping(); + testModelMapping() } -module.exports = { testModelMapping }; \ No newline at end of file +module.exports = { testModelMapping } diff --git a/scripts/test-pricing-fallback.js b/scripts/test-pricing-fallback.js index b325a340..039d00e8 100644 --- a/scripts/test-pricing-fallback.js +++ b/scripts/test-pricing-fallback.js @@ -1,92 +1,91 @@ #!/usr/bin/env node -const fs = require('fs'); -const path = require('path'); +const fs = require('fs') +const path = require('path') // 测试定价服务的fallback机制 async function testPricingFallback() { - console.log('🧪 Testing pricing service fallback mechanism...\n'); + console.log('🧪 Testing pricing service fallback mechanism...\n') // 备份现有的模型定价文件 - const dataDir = path.join(process.cwd(), 'data'); - const pricingFile = path.join(dataDir, 'model_pricing.json'); - const backupFile = path.join(dataDir, 'model_pricing.backup.json'); + const dataDir = path.join(process.cwd(), 'data') + const pricingFile = path.join(dataDir, 'model_pricing.json') + const backupFile = path.join(dataDir, 'model_pricing.backup.json') // 1. 备份现有文件 if (fs.existsSync(pricingFile)) { - console.log('📦 Backing up existing pricing file...'); - fs.copyFileSync(pricingFile, backupFile); + console.log('📦 Backing up existing pricing file...') + fs.copyFileSync(pricingFile, backupFile) } try { // 2. 删除现有定价文件以触发fallback if (fs.existsSync(pricingFile)) { - console.log('🗑️ Removing existing pricing file to test fallback...'); - fs.unlinkSync(pricingFile); + console.log('🗑️ Removing existing pricing file to test fallback...') + fs.unlinkSync(pricingFile) } // 3. 初始化定价服务 - console.log('🚀 Initializing pricing service...\n'); - + console.log('🚀 Initializing pricing service...\n') + // 清除require缓存以确保重新加载 - delete require.cache[require.resolve('../src/services/pricingService')]; - const pricingService = require('../src/services/pricingService'); + delete require.cache[require.resolve('../src/services/pricingService')] + const pricingService = require('../src/services/pricingService') // 模拟网络失败,强制使用fallback - const originalDownload = pricingService._downloadFromRemote; - pricingService._downloadFromRemote = function() { - return Promise.reject(new Error('Simulated network failure for testing')); - }; + const originalDownload = pricingService._downloadFromRemote + pricingService._downloadFromRemote = function () { + return Promise.reject(new Error('Simulated network failure for testing')) + } // 初始化服务 - await pricingService.initialize(); + await pricingService.initialize() // 4. 验证fallback是否工作 - console.log('\n📊 Verifying fallback data...'); - const status = pricingService.getStatus(); - console.log(` - Initialized: ${status.initialized}`); - console.log(` - Model count: ${status.modelCount}`); - console.log(` - Last updated: ${status.lastUpdated}`); + console.log('\n📊 Verifying fallback data...') + const status = pricingService.getStatus() + console.log(` - Initialized: ${status.initialized}`) + console.log(` - Model count: ${status.modelCount}`) + console.log(` - Last updated: ${status.lastUpdated}`) // 5. 测试获取模型定价 - const testModels = ['claude-3-opus-20240229', 'gpt-4', 'gemini-pro']; - console.log('\n💰 Testing model pricing retrieval:'); - + const testModels = ['claude-3-opus-20240229', 'gpt-4', 'gemini-pro'] + console.log('\n💰 Testing model pricing retrieval:') + for (const model of testModels) { - const pricing = pricingService.getModelPricing(model); + const pricing = pricingService.getModelPricing(model) if (pricing) { - console.log(` ✅ ${model}: Found pricing data`); + console.log(` ✅ ${model}: Found pricing data`) } else { - console.log(` ❌ ${model}: No pricing data`); + console.log(` ❌ ${model}: No pricing data`) } } // 6. 验证文件是否被创建 if (fs.existsSync(pricingFile)) { - console.log('\n✅ Fallback successfully created pricing file in data directory'); - const fileStats = fs.statSync(pricingFile); - console.log(` - File size: ${(fileStats.size / 1024).toFixed(2)} KB`); + console.log('\n✅ Fallback successfully created pricing file in data directory') + const fileStats = fs.statSync(pricingFile) + console.log(` - File size: ${(fileStats.size / 1024).toFixed(2)} KB`) } else { - console.log('\n❌ Fallback failed to create pricing file'); + console.log('\n❌ Fallback failed to create pricing file') } // 恢复原始下载函数 - pricingService._downloadFromRemote = originalDownload; - + pricingService._downloadFromRemote = originalDownload } finally { // 7. 恢复备份文件 if (fs.existsSync(backupFile)) { - console.log('\n📦 Restoring original pricing file...'); - fs.copyFileSync(backupFile, pricingFile); - fs.unlinkSync(backupFile); + console.log('\n📦 Restoring original pricing file...') + fs.copyFileSync(backupFile, pricingFile) + fs.unlinkSync(backupFile) } } - console.log('\n✨ Fallback mechanism test completed!'); + console.log('\n✨ Fallback mechanism test completed!') } // 运行测试 -testPricingFallback().catch(error => { - console.error('❌ Test failed:', error); - process.exit(1); -}); \ No newline at end of file +testPricingFallback().catch((error) => { + console.error('❌ Test failed:', error) + process.exit(1) +}) diff --git a/scripts/update-model-pricing.js b/scripts/update-model-pricing.js index 2af0fa34..0b1e2323 100644 --- a/scripts/update-model-pricing.js +++ b/scripts/update-model-pricing.js @@ -5,9 +5,9 @@ * 从 LiteLLM 仓库下载最新的模型价格和上下文窗口信息 */ -const fs = require('fs'); -const path = require('path'); -const https = require('https'); +const fs = require('fs') +const path = require('path') +const https = require('https') // 颜色输出 const colors = { @@ -18,7 +18,7 @@ const colors = { yellow: '\x1b[33m', blue: '\x1b[36m', magenta: '\x1b[35m' -}; +} // 日志函数 const log = { @@ -26,23 +26,29 @@ const log = { success: (msg) => console.log(`${colors.green}[SUCCESS]${colors.reset} ${msg}`), error: (msg) => console.error(`${colors.red}[ERROR]${colors.reset} ${msg}`), warn: (msg) => console.warn(`${colors.yellow}[WARNING]${colors.reset} ${msg}`) -}; +} // 配置 const config = { dataDir: path.join(process.cwd(), 'data'), pricingFile: path.join(process.cwd(), 'data', 'model_pricing.json'), - pricingUrl: 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json', - fallbackFile: path.join(process.cwd(), 'resources', 'model-pricing', 'model_prices_and_context_window.json'), + pricingUrl: + 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json', + fallbackFile: path.join( + process.cwd(), + 'resources', + 'model-pricing', + 'model_prices_and_context_window.json' + ), backupFile: path.join(process.cwd(), 'data', 'model_pricing.backup.json'), timeout: 30000 // 30秒超时 -}; +} // 创建数据目录 function ensureDataDir() { if (!fs.existsSync(config.dataDir)) { - fs.mkdirSync(config.dataDir, { recursive: true }); - log.info('Created data directory'); + fs.mkdirSync(config.dataDir, { recursive: true }) + log.info('Created data directory') } } @@ -50,213 +56,217 @@ function ensureDataDir() { function backupExistingFile() { if (fs.existsSync(config.pricingFile)) { try { - fs.copyFileSync(config.pricingFile, config.backupFile); - log.info('Backed up existing pricing file'); - return true; + fs.copyFileSync(config.pricingFile, config.backupFile) + log.info('Backed up existing pricing file') + return true } catch (error) { - log.warn(`Failed to backup existing file: ${error.message}`); - return false; + log.warn(`Failed to backup existing file: ${error.message}`) + return false } } - return false; + return false } // 恢复备份 function restoreBackup() { if (fs.existsSync(config.backupFile)) { try { - fs.copyFileSync(config.backupFile, config.pricingFile); - log.info('Restored from backup'); - return true; + fs.copyFileSync(config.backupFile, config.pricingFile) + log.info('Restored from backup') + return true } catch (error) { - log.error(`Failed to restore backup: ${error.message}`); - return false; + log.error(`Failed to restore backup: ${error.message}`) + return false } } - return false; + return false } // 下载价格数据 function downloadPricingData() { return new Promise((resolve, reject) => { - log.info(`Downloading model pricing data from LiteLLM...`); - log.info(`URL: ${config.pricingUrl}`); - + log.info('Downloading model pricing data from LiteLLM...') + log.info(`URL: ${config.pricingUrl}`) + const request = https.get(config.pricingUrl, (response) => { if (response.statusCode !== 200) { - reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)); - return; + reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)) + return } - let data = ''; - let downloadedBytes = 0; - + let data = '' + let downloadedBytes = 0 + response.on('data', (chunk) => { - data += chunk; - downloadedBytes += chunk.length; + data += chunk + downloadedBytes += chunk.length // 显示下载进度 - process.stdout.write(`\rDownloading... ${Math.round(downloadedBytes / 1024)}KB`); - }); + process.stdout.write(`\rDownloading... ${Math.round(downloadedBytes / 1024)}KB`) + }) response.on('end', () => { - process.stdout.write('\n'); // 换行 + process.stdout.write('\n') // 换行 try { - const jsonData = JSON.parse(data); - + const jsonData = JSON.parse(data) + // 验证数据结构 if (typeof jsonData !== 'object' || Object.keys(jsonData).length === 0) { - throw new Error('Invalid pricing data structure'); + throw new Error('Invalid pricing data structure') } - + // 保存到文件 - fs.writeFileSync(config.pricingFile, JSON.stringify(jsonData, null, 2)); - - const modelCount = Object.keys(jsonData).length; - const fileSize = Math.round(fs.statSync(config.pricingFile).size / 1024); - - log.success(`Downloaded pricing data for ${modelCount} models (${fileSize}KB)`); - + fs.writeFileSync(config.pricingFile, JSON.stringify(jsonData, null, 2)) + + const modelCount = Object.keys(jsonData).length + const fileSize = Math.round(fs.statSync(config.pricingFile).size / 1024) + + log.success(`Downloaded pricing data for ${modelCount} models (${fileSize}KB)`) + // 显示一些统计信息 - const claudeModels = Object.keys(jsonData).filter(k => k.includes('claude')).length; - const gptModels = Object.keys(jsonData).filter(k => k.includes('gpt')).length; - const geminiModels = Object.keys(jsonData).filter(k => k.includes('gemini')).length; - - log.info(`Model breakdown:`); - log.info(` - Claude models: ${claudeModels}`); - log.info(` - GPT models: ${gptModels}`); - log.info(` - Gemini models: ${geminiModels}`); - log.info(` - Other models: ${modelCount - claudeModels - gptModels - geminiModels}`); - - resolve(jsonData); + const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length + const gptModels = Object.keys(jsonData).filter((k) => k.includes('gpt')).length + const geminiModels = Object.keys(jsonData).filter((k) => k.includes('gemini')).length + + log.info('Model breakdown:') + log.info(` - Claude models: ${claudeModels}`) + log.info(` - GPT models: ${gptModels}`) + log.info(` - Gemini models: ${geminiModels}`) + log.info(` - Other models: ${modelCount - claudeModels - gptModels - geminiModels}`) + + resolve(jsonData) } catch (error) { - reject(new Error(`Failed to parse pricing data: ${error.message}`)); + reject(new Error(`Failed to parse pricing data: ${error.message}`)) } - }); - }); + }) + }) request.on('error', (error) => { - reject(new Error(`Network error: ${error.message}`)); - }); + reject(new Error(`Network error: ${error.message}`)) + }) request.setTimeout(config.timeout, () => { - request.destroy(); - reject(new Error(`Download timeout after ${config.timeout / 1000} seconds`)); - }); - }); + request.destroy() + reject(new Error(`Download timeout after ${config.timeout / 1000} seconds`)) + }) + }) } // 使用 fallback 文件 function useFallback() { - log.warn('Attempting to use fallback pricing data...'); - + log.warn('Attempting to use fallback pricing data...') + if (!fs.existsSync(config.fallbackFile)) { - log.error(`Fallback file not found: ${config.fallbackFile}`); - return false; + log.error(`Fallback file not found: ${config.fallbackFile}`) + return false } - + try { - const fallbackData = fs.readFileSync(config.fallbackFile, 'utf8'); - const jsonData = JSON.parse(fallbackData); - + const fallbackData = fs.readFileSync(config.fallbackFile, 'utf8') + const jsonData = JSON.parse(fallbackData) + // 保存到data目录 - fs.writeFileSync(config.pricingFile, JSON.stringify(jsonData, null, 2)); - - const modelCount = Object.keys(jsonData).length; - log.warn(`Using fallback pricing data for ${modelCount} models`); - log.info('Note: Fallback data may be outdated. Try updating again later.'); - - return true; + fs.writeFileSync(config.pricingFile, JSON.stringify(jsonData, null, 2)) + + const modelCount = Object.keys(jsonData).length + log.warn(`Using fallback pricing data for ${modelCount} models`) + log.info('Note: Fallback data may be outdated. Try updating again later.') + + return true } catch (error) { - log.error(`Failed to use fallback: ${error.message}`); - return false; + log.error(`Failed to use fallback: ${error.message}`) + return false } } // 显示当前状态 function showCurrentStatus() { if (fs.existsSync(config.pricingFile)) { - const stats = fs.statSync(config.pricingFile); - const fileAge = Date.now() - stats.mtime.getTime(); - const ageInHours = Math.round(fileAge / (60 * 60 * 1000)); - const ageInDays = Math.floor(ageInHours / 24); - - let ageString = ''; + const stats = fs.statSync(config.pricingFile) + const fileAge = Date.now() - stats.mtime.getTime() + const ageInHours = Math.round(fileAge / (60 * 60 * 1000)) + const ageInDays = Math.floor(ageInHours / 24) + + let ageString = '' if (ageInDays > 0) { - ageString = `${ageInDays} day${ageInDays > 1 ? 's' : ''} and ${ageInHours % 24} hour${(ageInHours % 24) !== 1 ? 's' : ''}`; + ageString = `${ageInDays} day${ageInDays > 1 ? 's' : ''} and ${ageInHours % 24} hour${ageInHours % 24 !== 1 ? 's' : ''}` } else { - ageString = `${ageInHours} hour${ageInHours !== 1 ? 's' : ''}`; + ageString = `${ageInHours} hour${ageInHours !== 1 ? 's' : ''}` } - - log.info(`Current pricing file age: ${ageString}`); - + + log.info(`Current pricing file age: ${ageString}`) + try { - const data = JSON.parse(fs.readFileSync(config.pricingFile, 'utf8')); - log.info(`Current file contains ${Object.keys(data).length} models`); + const data = JSON.parse(fs.readFileSync(config.pricingFile, 'utf8')) + log.info(`Current file contains ${Object.keys(data).length} models`) } catch (error) { - log.warn('Current file exists but could not be parsed'); + log.warn('Current file exists but could not be parsed') } } else { - log.info('No existing pricing file found'); + log.info('No existing pricing file found') } } // 主函数 async function main() { - console.log(`${colors.bright}${colors.blue}======================================${colors.reset}`); - console.log(`${colors.bright} Model Pricing Update Tool${colors.reset}`); - console.log(`${colors.bright}${colors.blue}======================================${colors.reset}\n`); - + console.log(`${colors.bright}${colors.blue}======================================${colors.reset}`) + console.log(`${colors.bright} Model Pricing Update Tool${colors.reset}`) + console.log( + `${colors.bright}${colors.blue}======================================${colors.reset}\n` + ) + // 显示当前状态 - showCurrentStatus(); - console.log(''); - + showCurrentStatus() + console.log('') + // 确保数据目录存在 - ensureDataDir(); - + ensureDataDir() + // 备份现有文件 - const hasBackup = backupExistingFile(); - + const hasBackup = backupExistingFile() + try { // 尝试下载最新数据 - await downloadPricingData(); - + await downloadPricingData() + // 清理备份文件(成功下载后) if (hasBackup && fs.existsSync(config.backupFile)) { - fs.unlinkSync(config.backupFile); - log.info('Cleaned up backup file'); + fs.unlinkSync(config.backupFile) + log.info('Cleaned up backup file') } - - console.log(`\n${colors.green}✅ Model pricing updated successfully!${colors.reset}`); - process.exit(0); + + console.log(`\n${colors.green}✅ Model pricing updated successfully!${colors.reset}`) + process.exit(0) } catch (error) { - log.error(`Download failed: ${error.message}`); - + log.error(`Download failed: ${error.message}`) + // 尝试恢复备份 if (hasBackup) { if (restoreBackup()) { - log.info('Original file restored'); + log.info('Original file restored') } } - + // 尝试使用 fallback if (useFallback()) { - console.log(`\n${colors.yellow}⚠️ Using fallback data (update completed with warnings)${colors.reset}`); - process.exit(0); + console.log( + `\n${colors.yellow}⚠️ Using fallback data (update completed with warnings)${colors.reset}` + ) + process.exit(0) } else { - console.log(`\n${colors.red}❌ Failed to update model pricing${colors.reset}`); - process.exit(1); + console.log(`\n${colors.red}❌ Failed to update model pricing${colors.reset}`) + process.exit(1) } } } // 处理未捕获的错误 process.on('unhandledRejection', (error) => { - log.error(`Unhandled error: ${error.message}`); - process.exit(1); -}); + log.error(`Unhandled error: ${error.message}`) + process.exit(1) +}) // 运行主函数 main().catch((error) => { - log.error(`Fatal error: ${error.message}`); - process.exit(1); -}); \ No newline at end of file + log.error(`Fatal error: ${error.message}`) + process.exit(1) +}) diff --git a/src/app.js b/src/app.js index 2a466496..08709f72 100644 --- a/src/app.js +++ b/src/app.js @@ -1,255 +1,268 @@ -const express = require('express'); -const cors = require('cors'); -const helmet = require('helmet'); -const morgan = require('morgan'); -const compression = require('compression'); -const path = require('path'); -const fs = require('fs'); -const bcrypt = require('bcryptjs'); +const express = require('express') +const cors = require('cors') +const helmet = require('helmet') +const compression = require('compression') +const path = require('path') +const fs = require('fs') +const bcrypt = require('bcryptjs') -const config = require('../config/config'); -const logger = require('./utils/logger'); -const redis = require('./models/redis'); -const pricingService = require('./services/pricingService'); +const config = require('../config/config') +const logger = require('./utils/logger') +const redis = require('./models/redis') +const pricingService = require('./services/pricingService') // Import routes -const apiRoutes = require('./routes/api'); -const adminRoutes = require('./routes/admin'); -const webRoutes = require('./routes/web'); -const apiStatsRoutes = require('./routes/apiStats'); -const geminiRoutes = require('./routes/geminiRoutes'); -const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes'); -const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes'); +const apiRoutes = require('./routes/api') +const adminRoutes = require('./routes/admin') +const webRoutes = require('./routes/web') +const apiStatsRoutes = require('./routes/apiStats') +const geminiRoutes = require('./routes/geminiRoutes') +const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes') +const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes') // Import middleware -const { - corsMiddleware, - requestLogger, - securityMiddleware, +const { + corsMiddleware, + requestLogger, + securityMiddleware, errorHandler, globalRateLimit, requestSizeLimit -} = require('./middleware/auth'); +} = require('./middleware/auth') class Application { constructor() { - this.app = express(); - this.server = null; + this.app = express() + this.server = null } async initialize() { try { // 🔗 连接Redis - logger.info('🔄 Connecting to Redis...'); - await redis.connect(); - logger.success('✅ Redis connected successfully'); - + logger.info('🔄 Connecting to Redis...') + await redis.connect() + logger.success('✅ Redis connected successfully') + // 💰 初始化价格服务 - logger.info('🔄 Initializing pricing service...'); - await pricingService.initialize(); - + logger.info('🔄 Initializing pricing service...') + await pricingService.initialize() + // 🔧 初始化管理员凭据 - logger.info('🔄 Initializing admin credentials...'); - await this.initializeAdmin(); - + logger.info('🔄 Initializing admin credentials...') + await this.initializeAdmin() + // 💰 初始化费用数据 - logger.info('💰 Checking cost data initialization...'); - const costInitService = require('./services/costInitService'); - const needsInit = await costInitService.needsInitialization(); + logger.info('💰 Checking cost data initialization...') + const costInitService = require('./services/costInitService') + const needsInit = await costInitService.needsInitialization() if (needsInit) { - logger.info('💰 Initializing cost data for all API Keys...'); - const result = await costInitService.initializeAllCosts(); - logger.info(`💰 Cost initialization completed: ${result.processed} processed, ${result.errors} errors`); + logger.info('💰 Initializing cost data for all API Keys...') + const result = await costInitService.initializeAllCosts() + logger.info( + `💰 Cost initialization completed: ${result.processed} processed, ${result.errors} errors` + ) } - + // 🕐 初始化Claude账户会话窗口 - logger.info('🕐 Initializing Claude account session windows...'); - const claudeAccountService = require('./services/claudeAccountService'); - await claudeAccountService.initializeSessionWindows(); - + logger.info('🕐 Initializing Claude account session windows...') + const claudeAccountService = require('./services/claudeAccountService') + await claudeAccountService.initializeSessionWindows() + // 超早期拦截 /admin-next/ 请求 - 在所有中间件之前 this.app.use((req, res, next) => { if (req.path === '/admin-next/' && req.method === 'GET') { - logger.warn(`🚨 INTERCEPTING /admin-next/ request at the very beginning!`); - const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist'); - const indexPath = path.join(adminSpaPath, 'index.html'); - + logger.warn('🚨 INTERCEPTING /admin-next/ request at the very beginning!') + const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist') + const indexPath = path.join(adminSpaPath, 'index.html') + if (fs.existsSync(indexPath)) { - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - return res.sendFile(indexPath); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + return res.sendFile(indexPath) } else { - logger.error('❌ index.html not found at:', indexPath); - return res.status(404).send('index.html not found'); + logger.error('❌ index.html not found at:', indexPath) + return res.status(404).send('index.html not found') } } - next(); - }); - + next() + }) + // 🛡️ 安全中间件 - this.app.use(helmet({ - contentSecurityPolicy: false, // 允许内联样式和脚本 - crossOriginEmbedderPolicy: false - })); - + this.app.use( + helmet({ + contentSecurityPolicy: false, // 允许内联样式和脚本 + crossOriginEmbedderPolicy: false + }) + ) + // 🌐 CORS if (config.web.enableCors) { - this.app.use(cors()); + this.app.use(cors()) } else { - this.app.use(corsMiddleware); + this.app.use(corsMiddleware) } - + // 📦 压缩 - 排除流式响应(SSE) - this.app.use(compression({ - filter: (req, res) => { - // 不压缩 Server-Sent Events - if (res.getHeader('Content-Type') === 'text/event-stream') { - return false; + this.app.use( + compression({ + filter: (req, res) => { + // 不压缩 Server-Sent Events + if (res.getHeader('Content-Type') === 'text/event-stream') { + return false + } + // 使用默认的压缩判断 + return compression.filter(req, res) } - // 使用默认的压缩判断 - return compression.filter(req, res); - } - })); - + }) + ) + // 🚦 全局速率限制(仅在生产环境启用) if (process.env.NODE_ENV === 'production') { - this.app.use(globalRateLimit); + this.app.use(globalRateLimit) } - + // 📏 请求大小限制 - this.app.use(requestSizeLimit); - + this.app.use(requestSizeLimit) + // 📝 请求日志(使用自定义logger而不是morgan) - this.app.use(requestLogger); - + this.app.use(requestLogger) + // 🔧 基础中间件 - this.app.use(express.json({ - limit: '10mb', - verify: (req, res, buf, encoding) => { - // 验证JSON格式 - if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) { - throw new Error('Invalid JSON: empty body'); + this.app.use( + express.json({ + limit: '10mb', + verify: (req, res, buf, encoding) => { + // 验证JSON格式 + if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) { + throw new Error('Invalid JSON: empty body') + } } - } - })); - this.app.use(express.urlencoded({ extended: true, limit: '10mb' })); - this.app.use(securityMiddleware); - + }) + ) + this.app.use(express.urlencoded({ extended: true, limit: '10mb' })) + this.app.use(securityMiddleware) + // 🎯 信任代理 if (config.server.trustProxy) { - this.app.set('trust proxy', 1); + this.app.set('trust proxy', 1) } // 调试中间件 - 拦截所有 /admin-next 请求 this.app.use((req, res, next) => { if (req.path.startsWith('/admin-next')) { - logger.info(`🔍 DEBUG: Incoming request - method: ${req.method}, path: ${req.path}, originalUrl: ${req.originalUrl}`); + logger.info( + `🔍 DEBUG: Incoming request - method: ${req.method}, path: ${req.path}, originalUrl: ${req.originalUrl}` + ) } - next(); - }); - + next() + }) + // 🎨 新版管理界面静态文件服务(必须在其他路由之前) - const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist'); + const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist') if (fs.existsSync(adminSpaPath)) { // 处理不带斜杠的路径,重定向到带斜杠的路径 this.app.get('/admin-next', (req, res) => { - res.redirect(301, '/admin-next/'); - }); - + res.redirect(301, '/admin-next/') + }) + // 使用 all 方法确保捕获所有 HTTP 方法 this.app.all('/admin-next/', (req, res) => { - logger.info('🎯 HIT: /admin-next/ route handler triggered!'); - logger.info(`Method: ${req.method}, Path: ${req.path}, URL: ${req.url}`); - + logger.info('🎯 HIT: /admin-next/ route handler triggered!') + logger.info(`Method: ${req.method}, Path: ${req.path}, URL: ${req.url}`) + if (req.method !== 'GET' && req.method !== 'HEAD') { - return res.status(405).send('Method Not Allowed'); + return res.status(405).send('Method Not Allowed') } - - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - res.sendFile(path.join(adminSpaPath, 'index.html')); - }); - + + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + res.sendFile(path.join(adminSpaPath, 'index.html')) + }) + // 处理所有其他 /admin-next/* 路径(但排除根路径) this.app.get('/admin-next/*', (req, res) => { // 如果是根路径,跳过(应该由上面的路由处理) if (req.path === '/admin-next/') { - logger.error('❌ ERROR: /admin-next/ should not reach here!'); - return res.status(500).send('Route configuration error'); + logger.error('❌ ERROR: /admin-next/ should not reach here!') + return res.status(500).send('Route configuration error') } - - const requestPath = req.path.replace('/admin-next/', ''); - + + const requestPath = req.path.replace('/admin-next/', '') + // 安全检查 - if (requestPath.includes('..') || requestPath.includes('//') || requestPath.includes('\\')) { - return res.status(400).json({ error: 'Invalid path' }); + if ( + requestPath.includes('..') || + requestPath.includes('//') || + requestPath.includes('\\') + ) { + return res.status(400).json({ error: 'Invalid path' }) } - + // 检查是否为静态资源 - const filePath = path.join(adminSpaPath, requestPath); - + const filePath = path.join(adminSpaPath, requestPath) + // 如果文件存在且是静态资源 if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { // 设置缓存头 if (filePath.endsWith('.js') || filePath.endsWith('.css')) { - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') } else if (filePath.endsWith('.html')) { - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') } - return res.sendFile(filePath); + return res.sendFile(filePath) } - + // 如果是静态资源但文件不存在 if (requestPath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf)$/i)) { - return res.status(404).send('Not found'); + return res.status(404).send('Not found') } - + // 其他所有路径返回 index.html(SPA 路由) - res.sendFile(path.join(adminSpaPath, 'index.html')); - }); - - logger.info('✅ Admin SPA (next) static files mounted at /admin-next/'); + res.sendFile(path.join(adminSpaPath, 'index.html')) + }) + + logger.info('✅ Admin SPA (next) static files mounted at /admin-next/') } else { - logger.warn('⚠️ Admin SPA dist directory not found, skipping /admin-next route'); + logger.warn('⚠️ Admin SPA dist directory not found, skipping /admin-next route') } // 🛣️ 路由 - this.app.use('/api', apiRoutes); - this.app.use('/claude', apiRoutes); // /claude 路由别名,与 /api 功能相同 - this.app.use('/admin', adminRoutes); + this.app.use('/api', apiRoutes) + this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同 + this.app.use('/admin', adminRoutes) // 使用 web 路由(包含 auth 和页面重定向) - this.app.use('/web', webRoutes); - this.app.use('/apiStats', apiStatsRoutes); - this.app.use('/gemini', geminiRoutes); - this.app.use('/openai/gemini', openaiGeminiRoutes); - this.app.use('/openai/claude', openaiClaudeRoutes); - + this.app.use('/web', webRoutes) + this.app.use('/apiStats', apiStatsRoutes) + this.app.use('/gemini', geminiRoutes) + this.app.use('/openai/gemini', openaiGeminiRoutes) + this.app.use('/openai/claude', openaiClaudeRoutes) + // 🏠 根路径重定向到新版管理界面 this.app.get('/', (req, res) => { - res.redirect('/admin-next/api-stats'); - }); - + res.redirect('/admin-next/api-stats') + }) + // 🏥 增强的健康检查端点 this.app.get('/health', async (req, res) => { try { - const timer = logger.timer('health-check'); - + const timer = logger.timer('health-check') + // 检查各个组件健康状态 const [redisHealth, loggerHealth] = await Promise.all([ this.checkRedisHealth(), this.checkLoggerHealth() - ]); - - const memory = process.memoryUsage(); - + ]) + + const memory = process.memoryUsage() + // 获取版本号:优先使用环境变量,其次VERSION文件,再次package.json,最后使用默认值 - let version = process.env.APP_VERSION || process.env.VERSION; + let version = process.env.APP_VERSION || process.env.VERSION if (!version) { try { // 尝试从VERSION文件读取 - const fs = require('fs'); - const path = require('path'); - const versionFile = path.join(__dirname, '..', 'VERSION'); + const fs = require('fs') + const path = require('path') + const versionFile = path.join(__dirname, '..', 'VERSION') if (fs.existsSync(versionFile)) { - version = fs.readFileSync(versionFile, 'utf8').trim(); + version = fs.readFileSync(versionFile, 'utf8').trim() } } catch (error) { // 忽略错误,继续尝试其他方式 @@ -257,13 +270,13 @@ class Application { } if (!version) { try { - const packageJson = require('../package.json'); - version = packageJson.version; + const packageJson = require('../package.json') + version = packageJson.version } catch (error) { - version = '1.0.0'; + version = '1.0.0' } } - + const health = { status: 'healthy', service: 'claude-relay-service', @@ -280,75 +293,74 @@ class Application { logger: loggerHealth }, stats: logger.getStats() - }; - - timer.end('completed'); - res.json(health); + } + + timer.end('completed') + res.json(health) } catch (error) { - logger.error('❌ Health check failed:', { error: error.message, stack: error.stack }); + logger.error('❌ Health check failed:', { error: error.message, stack: error.stack }) res.status(503).json({ status: 'unhealthy', error: error.message, timestamp: new Date().toISOString() - }); + }) } - }); - + }) + // 📊 指标端点 this.app.get('/metrics', async (req, res) => { try { - const stats = await redis.getSystemStats(); + const stats = await redis.getSystemStats() const metrics = { ...stats, uptime: process.uptime(), memory: process.memoryUsage(), timestamp: new Date().toISOString() - }; - - res.json(metrics); + } + + res.json(metrics) } catch (error) { - logger.error('❌ Metrics collection failed:', error); - res.status(500).json({ error: 'Failed to collect metrics' }); + logger.error('❌ Metrics collection failed:', error) + res.status(500).json({ error: 'Failed to collect metrics' }) } - }); - + }) + // 🚫 404 处理 this.app.use('*', (req, res) => { res.status(404).json({ error: 'Not Found', message: `Route ${req.originalUrl} not found`, timestamp: new Date().toISOString() - }); - }); - + }) + }) + // 🚨 错误处理 - this.app.use(errorHandler); - - logger.success('✅ Application initialized successfully'); - + this.app.use(errorHandler) + + logger.success('✅ Application initialized successfully') } catch (error) { - logger.error('💥 Application initialization failed:', error); - throw error; + logger.error('💥 Application initialization failed:', error) + throw error } } // 🔧 初始化管理员凭据(总是从 init.json 加载,确保数据一致性) async initializeAdmin() { try { - const initFilePath = path.join(__dirname, '..', 'data', 'init.json'); - + const initFilePath = path.join(__dirname, '..', 'data', 'init.json') + if (!fs.existsSync(initFilePath)) { - logger.warn('⚠️ No admin credentials found. Please run npm run setup first.'); - return; + logger.warn('⚠️ No admin credentials found. Please run npm run setup first.') + return } // 从 init.json 读取管理员凭据(作为唯一真实数据源) - const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')); - + const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')) + // 将明文密码哈希化 - const saltRounds = 10; - const passwordHash = await bcrypt.hash(initData.adminPassword, saltRounds); - + const saltRounds = 10 + const passwordHash = await bcrypt.hash(initData.adminPassword, saltRounds) + // 存储到Redis(每次启动都覆盖,确保与 init.json 同步) const adminCredentials = { username: initData.adminUsername, @@ -356,84 +368,90 @@ class Application { createdAt: initData.initializedAt || new Date().toISOString(), lastLogin: null, updatedAt: initData.updatedAt || null - }; - - await redis.setSession('admin_credentials', adminCredentials); - - logger.success('✅ Admin credentials loaded from init.json (single source of truth)'); - logger.info(`📋 Admin username: ${adminCredentials.username}`); - + } + + await redis.setSession('admin_credentials', adminCredentials) + + logger.success('✅ Admin credentials loaded from init.json (single source of truth)') + logger.info(`📋 Admin username: ${adminCredentials.username}`) } catch (error) { - logger.error('❌ Failed to initialize admin credentials:', { error: error.message, stack: error.stack }); - throw error; + logger.error('❌ Failed to initialize admin credentials:', { + error: error.message, + stack: error.stack + }) + throw error } } // 🔍 Redis健康检查 async checkRedisHealth() { try { - const start = Date.now(); - await redis.getClient().ping(); - const latency = Date.now() - start; - + const start = Date.now() + await redis.getClient().ping() + const latency = Date.now() - start + return { status: 'healthy', connected: redis.isConnected, latency: `${latency}ms` - }; + } } catch (error) { return { status: 'unhealthy', connected: false, error: error.message - }; + } } } // 📝 Logger健康检查 async checkLoggerHealth() { try { - const health = logger.healthCheck(); + const health = logger.healthCheck() return { status: health.healthy ? 'healthy' : 'unhealthy', ...health - }; + } } catch (error) { return { status: 'unhealthy', error: error.message - }; + } } } async start() { try { - await this.initialize(); - - this.server = this.app.listen(config.server.port, config.server.host, () => { - logger.start(`🚀 Claude Relay Service started on ${config.server.host}:${config.server.port}`); - logger.info(`🌐 Web interface: http://${config.server.host}:${config.server.port}/admin-next/api-stats`); - logger.info(`🔗 API endpoint: http://${config.server.host}:${config.server.port}/api/v1/messages`); - logger.info(`⚙️ Admin API: http://${config.server.host}:${config.server.port}/admin`); - logger.info(`🏥 Health check: http://${config.server.host}:${config.server.port}/health`); - logger.info(`📊 Metrics: http://${config.server.host}:${config.server.port}/metrics`); - }); + await this.initialize() - const serverTimeout = 600000; // 默认10分钟 - this.server.timeout = serverTimeout; - this.server.keepAliveTimeout = serverTimeout + 5000; // keepAlive 稍长一点 - logger.info(`⏱️ Server timeout set to ${serverTimeout}ms (${serverTimeout/1000}s)`); - + this.server = this.app.listen(config.server.port, config.server.host, () => { + logger.start( + `🚀 Claude Relay Service started on ${config.server.host}:${config.server.port}` + ) + logger.info( + `🌐 Web interface: http://${config.server.host}:${config.server.port}/admin-next/api-stats` + ) + logger.info( + `🔗 API endpoint: http://${config.server.host}:${config.server.port}/api/v1/messages` + ) + logger.info(`⚙️ Admin API: http://${config.server.host}:${config.server.port}/admin`) + logger.info(`🏥 Health check: http://${config.server.host}:${config.server.port}/health`) + logger.info(`📊 Metrics: http://${config.server.host}:${config.server.port}/metrics`) + }) + + const serverTimeout = 600000 // 默认10分钟 + this.server.timeout = serverTimeout + this.server.keepAliveTimeout = serverTimeout + 5000 // keepAlive 稍长一点 + logger.info(`⏱️ Server timeout set to ${serverTimeout}ms (${serverTimeout / 1000}s)`) // 🔄 定期清理任务 - this.startCleanupTasks(); - + this.startCleanupTasks() + // 🛑 优雅关闭 - this.setupGracefulShutdown(); - + this.setupGracefulShutdown() } catch (error) { - logger.error('💥 Failed to start server:', error); - process.exit(1); + logger.error('💥 Failed to start server:', error) + process.exit(1) } } @@ -441,87 +459,91 @@ class Application { // 🧹 每小时清理一次过期数据 setInterval(async () => { try { - logger.info('🧹 Starting scheduled cleanup...'); - - const apiKeyService = require('./services/apiKeyService'); - const claudeAccountService = require('./services/claudeAccountService'); - + logger.info('🧹 Starting scheduled cleanup...') + + const apiKeyService = require('./services/apiKeyService') + const claudeAccountService = require('./services/claudeAccountService') + const [expiredKeys, errorAccounts] = await Promise.all([ apiKeyService.cleanupExpiredKeys(), claudeAccountService.cleanupErrorAccounts() - ]); - - await redis.cleanup(); - - logger.success(`🧹 Cleanup completed: ${expiredKeys} expired keys, ${errorAccounts} error accounts reset`); - } catch (error) { - logger.error('❌ Cleanup task failed:', error); - } - }, config.system.cleanupInterval); + ]) - logger.info(`🔄 Cleanup tasks scheduled every ${config.system.cleanupInterval / 1000 / 60} minutes`); + await redis.cleanup() + + logger.success( + `🧹 Cleanup completed: ${expiredKeys} expired keys, ${errorAccounts} error accounts reset` + ) + } catch (error) { + logger.error('❌ Cleanup task failed:', error) + } + }, config.system.cleanupInterval) + + logger.info( + `🔄 Cleanup tasks scheduled every ${config.system.cleanupInterval / 1000 / 60} minutes` + ) } setupGracefulShutdown() { const shutdown = async (signal) => { - logger.info(`🛑 Received ${signal}, starting graceful shutdown...`); - + logger.info(`🛑 Received ${signal}, starting graceful shutdown...`) + if (this.server) { this.server.close(async () => { - logger.info('🚪 HTTP server closed'); - + logger.info('🚪 HTTP server closed') + // 清理 pricing service 的文件监听器 try { - pricingService.cleanup(); - logger.info('💰 Pricing service cleaned up'); + pricingService.cleanup() + logger.info('💰 Pricing service cleaned up') } catch (error) { - logger.error('❌ Error cleaning up pricing service:', error); + logger.error('❌ Error cleaning up pricing service:', error) } - + try { - await redis.disconnect(); - logger.info('👋 Redis disconnected'); + await redis.disconnect() + logger.info('👋 Redis disconnected') } catch (error) { - logger.error('❌ Error disconnecting Redis:', error); + logger.error('❌ Error disconnecting Redis:', error) } - - logger.success('✅ Graceful shutdown completed'); - process.exit(0); - }); + + logger.success('✅ Graceful shutdown completed') + process.exit(0) + }) // 强制关闭超时 setTimeout(() => { - logger.warn('⚠️ Forced shutdown due to timeout'); - process.exit(1); - }, 10000); + logger.warn('⚠️ Forced shutdown due to timeout') + process.exit(1) + }, 10000) } else { - process.exit(0); + process.exit(0) } - }; + } + + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) - process.on('SIGTERM', () => shutdown('SIGTERM')); - process.on('SIGINT', () => shutdown('SIGINT')); - // 处理未捕获异常 process.on('uncaughtException', (error) => { - logger.error('💥 Uncaught exception:', error); - shutdown('uncaughtException'); - }); - + logger.error('💥 Uncaught exception:', error) + shutdown('uncaughtException') + }) + process.on('unhandledRejection', (reason, promise) => { - logger.error('💥 Unhandled rejection at:', promise, 'reason:', reason); - shutdown('unhandledRejection'); - }); + logger.error('💥 Unhandled rejection at:', promise, 'reason:', reason) + shutdown('unhandledRejection') + }) } } // 启动应用 if (require.main === module) { - const app = new Application(); + const app = new Application() app.start().catch((error) => { - logger.error('💥 Application startup failed:', error); - process.exit(1); - }); + logger.error('💥 Application startup failed:', error) + process.exit(1) + }) } -module.exports = Application; \ No newline at end of file +module.exports = Application diff --git a/src/cli/initCosts.js b/src/cli/initCosts.js index 0dc4aff8..35ab75d4 100644 --- a/src/cli/initCosts.js +++ b/src/cli/initCosts.js @@ -1,32 +1,35 @@ #!/usr/bin/env node -const costInitService = require('../services/costInitService'); -const logger = require('../utils/logger'); -const redis = require('../models/redis'); +const costInitService = require('../services/costInitService') +const logger = require('../utils/logger') +const redis = require('../models/redis') async function main() { try { // 连接Redis - await redis.connect(); - - console.log('💰 Starting cost data initialization...\n'); - + await redis.connect() + + console.log('💰 Starting cost data initialization...\n') + // 执行初始化 - const result = await costInitService.initializeAllCosts(); - - console.log('\n✅ Cost initialization completed!'); - console.log(` Processed: ${result.processed} API Keys`); - console.log(` Errors: ${result.errors}`); - + const result = await costInitService.initializeAllCosts() + + console.log('\n✅ Cost initialization completed!') + console.log(` Processed: ${result.processed} API Keys`) + console.log(` Errors: ${result.errors}`) + // 断开连接 - await redis.disconnect(); - process.exit(0); + await redis.disconnect() + throw new Error('INIT_COSTS_SUCCESS') } catch (error) { - console.error('\n❌ Cost initialization failed:', error.message); - logger.error('Cost initialization failed:', error); - process.exit(1); + if (error.message === 'INIT_COSTS_SUCCESS') { + return + } + console.error('\n❌ Cost initialization failed:', error.message) + logger.error('Cost initialization failed:', error) + throw error } } // 运行主函数 -main(); \ No newline at end of file +main() diff --git a/src/middleware/auth.js b/src/middleware/auth.js index dc441781..a82971dc 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,205 +1,230 @@ -const apiKeyService = require('../services/apiKeyService'); -const logger = require('../utils/logger'); -const redis = require('../models/redis'); -const { RateLimiterRedis } = require('rate-limiter-flexible'); -const config = require('../../config/config'); +const apiKeyService = require('../services/apiKeyService') +const logger = require('../utils/logger') +const redis = require('../models/redis') +const { RateLimiterRedis } = require('rate-limiter-flexible') +const config = require('../../config/config') // 🔑 API Key验证中间件(优化版) const authenticateApiKey = async (req, res, next) => { - const startTime = Date.now(); - + const startTime = Date.now() + try { // 安全提取API Key,支持多种格式 - const apiKey = req.headers['x-api-key'] || - req.headers['x-goog-api-key'] || - req.headers['authorization']?.replace(/^Bearer\s+/i, '') || - req.headers['api-key']; - + const apiKey = + req.headers['x-api-key'] || + req.headers['x-goog-api-key'] || + req.headers['authorization']?.replace(/^Bearer\s+/i, '') || + req.headers['api-key'] + if (!apiKey) { - logger.security(`🔒 Missing API key attempt from ${req.ip || 'unknown'}`); + logger.security(`🔒 Missing API key attempt from ${req.ip || 'unknown'}`) return res.status(401).json({ error: 'Missing API key', message: 'Please provide an API key in the x-api-key header or Authorization header' - }); + }) } // 基本API Key格式验证 if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { - logger.security(`🔒 Invalid API key format from ${req.ip || 'unknown'}`); + logger.security(`🔒 Invalid API key format from ${req.ip || 'unknown'}`) return res.status(401).json({ error: 'Invalid API key format', message: 'API key format is invalid' - }); + }) } // 验证API Key(带缓存优化) - const validation = await apiKeyService.validateApiKey(apiKey); - + const validation = await apiKeyService.validateApiKey(apiKey) + if (!validation.valid) { - const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; - logger.security(`🔒 Invalid API key attempt: ${validation.error} from ${clientIP}`); + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.security(`🔒 Invalid API key attempt: ${validation.error} from ${clientIP}`) return res.status(401).json({ error: 'Invalid API key', message: validation.error - }); + }) } // 🔒 检查客户端限制 - if (validation.keyData.enableClientRestriction && validation.keyData.allowedClients?.length > 0) { - const userAgent = req.headers['user-agent'] || ''; - const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; - + if ( + validation.keyData.enableClientRestriction && + validation.keyData.allowedClients?.length > 0 + ) { + const userAgent = req.headers['user-agent'] || '' + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + // 记录客户端限制检查开始 - logger.api(`🔍 Checking client restriction for key: ${validation.keyData.id} (${validation.keyData.name})`); - logger.api(` User-Agent: "${userAgent}"`); - logger.api(` Allowed clients: ${validation.keyData.allowedClients.join(', ')}`); - - let clientAllowed = false; - let matchedClient = null; - + logger.api( + `🔍 Checking client restriction for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + logger.api(` User-Agent: "${userAgent}"`) + logger.api(` Allowed clients: ${validation.keyData.allowedClients.join(', ')}`) + + let clientAllowed = false + let matchedClient = null + // 获取预定义客户端列表,如果配置不存在则使用默认值 - const predefinedClients = config.clientRestrictions?.predefinedClients || []; - const allowCustomClients = config.clientRestrictions?.allowCustomClients || false; - + const predefinedClients = config.clientRestrictions?.predefinedClients || [] + const allowCustomClients = config.clientRestrictions?.allowCustomClients || false + // 遍历允许的客户端列表 for (const allowedClientId of validation.keyData.allowedClients) { // 在预定义客户端列表中查找 - const predefinedClient = predefinedClients.find( - client => client.id === allowedClientId - ); - + const predefinedClient = predefinedClients.find((client) => client.id === allowedClientId) + if (predefinedClient) { // 使用预定义的正则表达式匹配 User-Agent - if (predefinedClient.userAgentPattern && predefinedClient.userAgentPattern.test(userAgent)) { - clientAllowed = true; - matchedClient = predefinedClient.name; - break; + if ( + predefinedClient.userAgentPattern && + predefinedClient.userAgentPattern.test(userAgent) + ) { + clientAllowed = true + matchedClient = predefinedClient.name + break } } else if (allowCustomClients) { // 如果允许自定义客户端,这里可以添加自定义客户端的验证逻辑 // 目前暂时跳过自定义客户端 - continue; + continue } } - + if (!clientAllowed) { - logger.security(`🚫 Client restriction failed for key: ${validation.keyData.id} (${validation.keyData.name}) from ${clientIP}, User-Agent: ${userAgent}`); + logger.security( + `🚫 Client restriction failed for key: ${validation.keyData.id} (${validation.keyData.name}) from ${clientIP}, User-Agent: ${userAgent}` + ) return res.status(403).json({ error: 'Client not allowed', message: 'Your client is not authorized to use this API key', allowedClients: validation.keyData.allowedClients - }); + }) } - - logger.api(`✅ Client validated: ${matchedClient} for key: ${validation.keyData.id} (${validation.keyData.name})`); - logger.api(` Matched client: ${matchedClient} with User-Agent: "${userAgent}"`); + + logger.api( + `✅ Client validated: ${matchedClient} for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + logger.api(` Matched client: ${matchedClient} with User-Agent: "${userAgent}"`) } // 检查并发限制 - const concurrencyLimit = validation.keyData.concurrencyLimit || 0; + const concurrencyLimit = validation.keyData.concurrencyLimit || 0 if (concurrencyLimit > 0) { - const currentConcurrency = await redis.incrConcurrency(validation.keyData.id); - logger.api(`📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}`); - + const currentConcurrency = await redis.incrConcurrency(validation.keyData.id) + logger.api( + `📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}` + ) + if (currentConcurrency > concurrencyLimit) { // 如果超过限制,立即减少计数 - await redis.decrConcurrency(validation.keyData.id); - logger.security(`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}`); + await redis.decrConcurrency(validation.keyData.id) + logger.security( + `🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}` + ) return res.status(429).json({ error: 'Concurrency limit exceeded', message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`, currentConcurrency: currentConcurrency - 1, concurrencyLimit - }); + }) } - + // 使用标志位确保只减少一次 - let concurrencyDecremented = false; - + let concurrencyDecremented = false + const decrementConcurrency = async () => { if (!concurrencyDecremented) { - concurrencyDecremented = true; + concurrencyDecremented = true try { - const newCount = await redis.decrConcurrency(validation.keyData.id); - logger.api(`📉 Decremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}`); + const newCount = await redis.decrConcurrency(validation.keyData.id) + logger.api( + `📉 Decremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}` + ) } catch (error) { - logger.error(`Failed to decrement concurrency for key ${validation.keyData.id}:`, error); + logger.error(`Failed to decrement concurrency for key ${validation.keyData.id}:`, error) } } - }; - + } + // 监听最可靠的事件(避免重复监听) // res.on('close') 是最可靠的,会在连接关闭时触发 res.once('close', () => { - logger.api(`🔌 Response closed for key: ${validation.keyData.id} (${validation.keyData.name})`); - decrementConcurrency(); - }); - + logger.api( + `🔌 Response closed for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + decrementConcurrency() + }) + // req.on('close') 作为备用,处理请求端断开 req.once('close', () => { - logger.api(`🔌 Request closed for key: ${validation.keyData.id} (${validation.keyData.name})`); - decrementConcurrency(); - }); - + logger.api( + `🔌 Request closed for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + decrementConcurrency() + }) + // res.on('finish') 处理正常完成的情况 res.once('finish', () => { - logger.api(`✅ Response finished for key: ${validation.keyData.id} (${validation.keyData.name})`); - decrementConcurrency(); - }); - + logger.api( + `✅ Response finished for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + decrementConcurrency() + }) + // 存储并发信息到请求对象,便于后续处理 req.concurrencyInfo = { apiKeyId: validation.keyData.id, apiKeyName: validation.keyData.name, decrementConcurrency - }; + } } // 检查时间窗口限流 - const rateLimitWindow = validation.keyData.rateLimitWindow || 0; - const rateLimitRequests = validation.keyData.rateLimitRequests || 0; - + const rateLimitWindow = validation.keyData.rateLimitWindow || 0 + const rateLimitRequests = validation.keyData.rateLimitRequests || 0 + if (rateLimitWindow > 0 && (rateLimitRequests > 0 || validation.keyData.tokenLimit > 0)) { - const windowStartKey = `rate_limit:window_start:${validation.keyData.id}`; - const requestCountKey = `rate_limit:requests:${validation.keyData.id}`; - const tokenCountKey = `rate_limit:tokens:${validation.keyData.id}`; - - const now = Date.now(); - const windowDuration = rateLimitWindow * 60 * 1000; // 转换为毫秒 - + const windowStartKey = `rate_limit:window_start:${validation.keyData.id}` + const requestCountKey = `rate_limit:requests:${validation.keyData.id}` + const tokenCountKey = `rate_limit:tokens:${validation.keyData.id}` + + const now = Date.now() + const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒 + // 获取窗口开始时间 - let windowStart = await redis.getClient().get(windowStartKey); - + let windowStart = await redis.getClient().get(windowStartKey) + if (!windowStart) { // 第一次请求,设置窗口开始时间 - await redis.getClient().set(windowStartKey, now, 'PX', windowDuration); - await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration); - await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration); - windowStart = now; + await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) + await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) + await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) + windowStart = now } else { - windowStart = parseInt(windowStart); - + windowStart = parseInt(windowStart) + // 检查窗口是否已过期 if (now - windowStart >= windowDuration) { // 窗口已过期,重置 - await redis.getClient().set(windowStartKey, now, 'PX', windowDuration); - await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration); - await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration); - windowStart = now; + await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) + await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) + await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) + windowStart = now } } - + // 获取当前计数 - const currentRequests = parseInt(await redis.getClient().get(requestCountKey) || '0'); - const currentTokens = parseInt(await redis.getClient().get(tokenCountKey) || '0'); - + const currentRequests = parseInt((await redis.getClient().get(requestCountKey)) || '0') + const currentTokens = parseInt((await redis.getClient().get(tokenCountKey)) || '0') + // 检查请求次数限制 if (rateLimitRequests > 0 && currentRequests >= rateLimitRequests) { - const resetTime = new Date(windowStart + windowDuration); - const remainingMinutes = Math.ceil((resetTime - now) / 60000); - - logger.security(`🚦 Rate limit exceeded (requests) for key: ${validation.keyData.id} (${validation.keyData.name}), requests: ${currentRequests}/${rateLimitRequests}`); - + const resetTime = new Date(windowStart + windowDuration) + const remainingMinutes = Math.ceil((resetTime - now) / 60000) + + logger.security( + `🚦 Rate limit exceeded (requests) for key: ${validation.keyData.id} (${validation.keyData.name}), requests: ${currentRequests}/${rateLimitRequests}` + ) + return res.status(429).json({ error: 'Rate limit exceeded', message: `已达到请求次数限制 (${rateLimitRequests} 次),将在 ${remainingMinutes} 分钟后重置`, @@ -207,17 +232,19 @@ const authenticateApiKey = async (req, res, next) => { requestLimit: rateLimitRequests, resetAt: resetTime.toISOString(), remainingMinutes - }); + }) } - + // 检查Token使用量限制 - const tokenLimit = parseInt(validation.keyData.tokenLimit); + const tokenLimit = parseInt(validation.keyData.tokenLimit) if (tokenLimit > 0 && currentTokens >= tokenLimit) { - const resetTime = new Date(windowStart + windowDuration); - const remainingMinutes = Math.ceil((resetTime - now) / 60000); - - logger.security(`🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}`); - + const resetTime = new Date(windowStart + windowDuration) + const remainingMinutes = Math.ceil((resetTime - now) / 60000) + + logger.security( + `🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}` + ) + return res.status(429).json({ error: 'Rate limit exceeded', message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`, @@ -225,12 +252,12 @@ const authenticateApiKey = async (req, res, next) => { tokenLimit, resetAt: resetTime.toISOString(), remainingMinutes - }); + }) } - + // 增加请求计数 - await redis.getClient().incr(requestCountKey); - + await redis.getClient().incr(requestCountKey) + // 存储限流信息到请求对象 req.rateLimitInfo = { windowStart, @@ -241,30 +268,34 @@ const authenticateApiKey = async (req, res, next) => { currentTokens, rateLimitRequests, tokenLimit - }; + } } - + // 检查每日费用限制 - const dailyCostLimit = validation.keyData.dailyCostLimit || 0; + const dailyCostLimit = validation.keyData.dailyCostLimit || 0 if (dailyCostLimit > 0) { - const dailyCost = validation.keyData.dailyCost || 0; - + const dailyCost = validation.keyData.dailyCost || 0 + if (dailyCost >= dailyCostLimit) { - logger.security(`💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`); - + logger.security( + `💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` + ) + return res.status(429).json({ error: 'Daily cost limit exceeded', message: `已达到每日费用限制 ($${dailyCostLimit})`, currentCost: dailyCost, costLimit: dailyCostLimit, resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() // 明天0点重置 - }); + }) } - + // 记录当前费用使用情况 - logger.api(`💰 Cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`); + logger.api( + `💰 Cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` + ) } - + // 将验证信息添加到请求对象(只包含必要信息) req.apiKey = { id: validation.keyData.id, @@ -283,97 +314,108 @@ const authenticateApiKey = async (req, res, next) => { dailyCostLimit: validation.keyData.dailyCostLimit, dailyCost: validation.keyData.dailyCost, usage: validation.keyData.usage - }; - req.usage = validation.keyData.usage; - - const authDuration = Date.now() - startTime; - const userAgent = req.headers['user-agent'] || 'No User-Agent'; - logger.api(`🔓 Authenticated request from key: ${validation.keyData.name} (${validation.keyData.id}) in ${authDuration}ms`); - logger.api(` User-Agent: "${userAgent}"`); - - next(); + } + req.usage = validation.keyData.usage + + const authDuration = Date.now() - startTime + const userAgent = req.headers['user-agent'] || 'No User-Agent' + logger.api( + `🔓 Authenticated request from key: ${validation.keyData.name} (${validation.keyData.id}) in ${authDuration}ms` + ) + logger.api(` User-Agent: "${userAgent}"`) + + return next() } catch (error) { - const authDuration = Date.now() - startTime; + const authDuration = Date.now() - startTime logger.error(`❌ Authentication middleware error (${authDuration}ms):`, { error: error.message, stack: error.stack, ip: req.ip, userAgent: req.get('User-Agent'), url: req.originalUrl - }); - - res.status(500).json({ + }) + + return res.status(500).json({ error: 'Authentication error', message: 'Internal server error during authentication' - }); + }) } -}; +} // 🛡️ 管理员验证中间件(优化版) const authenticateAdmin = async (req, res, next) => { - const startTime = Date.now(); - + const startTime = Date.now() + try { // 安全提取token,支持多种方式 - const token = req.headers['authorization']?.replace(/^Bearer\s+/i, '') || - req.cookies?.adminToken || - req.headers['x-admin-token']; - + const token = + req.headers['authorization']?.replace(/^Bearer\s+/i, '') || + req.cookies?.adminToken || + req.headers['x-admin-token'] + if (!token) { - logger.security(`🔒 Missing admin token attempt from ${req.ip || 'unknown'}`); + logger.security(`🔒 Missing admin token attempt from ${req.ip || 'unknown'}`) return res.status(401).json({ error: 'Missing admin token', message: 'Please provide an admin token' - }); + }) } // 基本token格式验证 if (typeof token !== 'string' || token.length < 32 || token.length > 512) { - logger.security(`🔒 Invalid admin token format from ${req.ip || 'unknown'}`); + logger.security(`🔒 Invalid admin token format from ${req.ip || 'unknown'}`) return res.status(401).json({ error: 'Invalid admin token format', message: 'Admin token format is invalid' - }); + }) } // 获取管理员会话(带超时处理) const adminSession = await Promise.race([ redis.getSession(token), - new Promise((_, reject) => + new Promise((_, reject) => setTimeout(() => reject(new Error('Session lookup timeout')), 5000) ) - ]); - + ]) + if (!adminSession || Object.keys(adminSession).length === 0) { - logger.security(`🔒 Invalid admin token attempt from ${req.ip || 'unknown'}`); + logger.security(`🔒 Invalid admin token attempt from ${req.ip || 'unknown'}`) return res.status(401).json({ error: 'Invalid admin token', message: 'Invalid or expired admin session' - }); + }) } // 检查会话活跃性(可选:检查最后活动时间) - const now = new Date(); - const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime); - const inactiveDuration = now - lastActivity; - const maxInactivity = 24 * 60 * 60 * 1000; // 24小时 + const now = new Date() + const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime) + const inactiveDuration = now - lastActivity + const maxInactivity = 24 * 60 * 60 * 1000 // 24小时 if (inactiveDuration > maxInactivity) { - logger.security(`🔒 Expired admin session for ${adminSession.username} from ${req.ip || 'unknown'}`); - await redis.deleteSession(token); // 清理过期会话 + logger.security( + `🔒 Expired admin session for ${adminSession.username} from ${req.ip || 'unknown'}` + ) + await redis.deleteSession(token) // 清理过期会话 return res.status(401).json({ error: 'Session expired', message: 'Admin session has expired due to inactivity' - }); + }) } // 更新最后活动时间(异步,不阻塞请求) - redis.setSession(token, { - ...adminSession, - lastActivity: now.toISOString() - }, 86400).catch(error => { - logger.error('Failed to update admin session activity:', error); - }); + redis + .setSession( + token, + { + ...adminSession, + lastActivity: now.toISOString() + }, + 86400 + ) + .catch((error) => { + logger.error('Failed to update admin session activity:', error) + }) // 设置管理员信息(只包含必要信息) req.admin = { @@ -381,98 +423,99 @@ const authenticateAdmin = async (req, res, next) => { username: adminSession.username, sessionId: token, loginTime: adminSession.loginTime - }; - - const authDuration = Date.now() - startTime; - logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`); - - next(); + } + + const authDuration = Date.now() - startTime + logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`) + + return next() } catch (error) { - const authDuration = Date.now() - startTime; + const authDuration = Date.now() - startTime logger.error(`❌ Admin authentication error (${authDuration}ms):`, { error: error.message, ip: req.ip, userAgent: req.get('User-Agent'), url: req.originalUrl - }); - - res.status(500).json({ + }) + + return res.status(500).json({ error: 'Authentication error', message: 'Internal server error during admin authentication' - }); + }) } -}; +} // 注意:使用统计现在直接在/api/v1/messages路由中处理, // 以便从Claude API响应中提取真实的usage数据 // 🚦 CORS中间件(优化版) const corsMiddleware = (req, res, next) => { - const origin = req.headers.origin; - + const { origin } = req.headers + // 允许的源(可以从配置文件读取) const allowedOrigins = [ 'http://localhost:3000', 'https://localhost:3000', 'http://127.0.0.1:3000', 'https://127.0.0.1:3000' - ]; - + ] + // 设置CORS头 if (allowedOrigins.includes(origin) || !origin) { - res.header('Access-Control-Allow-Origin', origin || '*'); + res.header('Access-Control-Allow-Origin', origin || '*') } - - res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.header('Access-Control-Allow-Headers', [ - 'Origin', - 'X-Requested-With', - 'Content-Type', - 'Accept', - 'Authorization', - 'x-api-key', - 'api-key', - 'x-admin-token' - ].join(', ')); - - res.header('Access-Control-Expose-Headers', [ - 'X-Request-ID', - 'Content-Type' - ].join(', ')); - - res.header('Access-Control-Max-Age', '86400'); // 24小时预检缓存 - res.header('Access-Control-Allow-Credentials', 'true'); - + + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + res.header( + 'Access-Control-Allow-Headers', + [ + 'Origin', + 'X-Requested-With', + 'Content-Type', + 'Accept', + 'Authorization', + 'x-api-key', + 'api-key', + 'x-admin-token' + ].join(', ') + ) + + res.header('Access-Control-Expose-Headers', ['X-Request-ID', 'Content-Type'].join(', ')) + + res.header('Access-Control-Max-Age', '86400') // 24小时预检缓存 + res.header('Access-Control-Allow-Credentials', 'true') + if (req.method === 'OPTIONS') { - res.status(204).end(); + res.status(204).end() } else { - next(); + next() } -}; +} // 📝 请求日志中间件(优化版) const requestLogger = (req, res, next) => { - const start = Date.now(); - const requestId = Math.random().toString(36).substring(2, 15); - + const start = Date.now() + const requestId = Math.random().toString(36).substring(2, 15) + // 添加请求ID到请求对象 - req.requestId = requestId; - res.setHeader('X-Request-ID', requestId); - + req.requestId = requestId + res.setHeader('X-Request-ID', requestId) + // 获取客户端信息 - const clientIP = req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress || 'unknown'; - const userAgent = req.get('User-Agent') || 'unknown'; - const referer = req.get('Referer') || 'none'; - + const clientIP = req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress || 'unknown' + const userAgent = req.get('User-Agent') || 'unknown' + const referer = req.get('Referer') || 'none' + // 记录请求开始 - if (req.originalUrl !== '/health') { // 避免健康检查日志过多 - logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`); + if (req.originalUrl !== '/health') { + // 避免健康检查日志过多 + logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`) } - + res.on('finish', () => { - const duration = Date.now() - start; - const contentLength = res.get('Content-Length') || '0'; - + const duration = Date.now() - start + const contentLength = res.get('Content-Length') || '0' + // 构建日志元数据 const logMetadata = { requestId, @@ -484,97 +527,111 @@ const requestLogger = (req, res, next) => { ip: clientIP, userAgent, referer - }; - + } + // 根据状态码选择日志级别 if (res.statusCode >= 500) { - logger.error(`◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`, logMetadata); + logger.error( + `◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`, + logMetadata + ) } else if (res.statusCode >= 400) { - logger.warn(`◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`, logMetadata); + logger.warn( + `◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`, + logMetadata + ) } else if (req.originalUrl !== '/health') { - logger.request( req.method, req.originalUrl, res.statusCode, duration, logMetadata); + logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata) } - + // API Key相关日志 if (req.apiKey) { - logger.api(`📱 [${requestId}] Request from ${req.apiKey.name} (${req.apiKey.id}) | ${duration}ms`); + logger.api( + `📱 [${requestId}] Request from ${req.apiKey.name} (${req.apiKey.id}) | ${duration}ms` + ) } - + // 慢请求警告 if (duration > 5000) { - logger.warn(`🐌 [${requestId}] Slow request detected: ${duration}ms for ${req.method} ${req.originalUrl}`); + logger.warn( + `🐌 [${requestId}] Slow request detected: ${duration}ms for ${req.method} ${req.originalUrl}` + ) } - }); - + }) + res.on('error', (error) => { - const duration = Date.now() - start; - logger.error(`💥 [${requestId}] Response error after ${duration}ms:`, error); - }); - - next(); -}; + const duration = Date.now() - start + logger.error(`💥 [${requestId}] Response error after ${duration}ms:`, error) + }) + + next() +} // 🛡️ 安全中间件(增强版) const securityMiddleware = (req, res, next) => { // 设置基础安全头 - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('X-Frame-Options', 'DENY'); - res.setHeader('X-XSS-Protection', '1; mode=block'); - res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); - + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('X-Frame-Options', 'DENY') + res.setHeader('X-XSS-Protection', '1; mode=block') + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin') + // 添加更多安全头 - res.setHeader('X-DNS-Prefetch-Control', 'off'); - res.setHeader('X-Download-Options', 'noopen'); - res.setHeader('X-Permitted-Cross-Domain-Policies', 'none'); - + res.setHeader('X-DNS-Prefetch-Control', 'off') + res.setHeader('X-Download-Options', 'noopen') + res.setHeader('X-Permitted-Cross-Domain-Policies', 'none') + // Cross-Origin-Opener-Policy (仅对可信来源设置) - const host = req.get('host') || ''; - const isLocalhost = host.includes('localhost') || host.includes('127.0.0.1') || host.includes('0.0.0.0'); - const isHttps = req.secure || req.headers['x-forwarded-proto'] === 'https'; - + const host = req.get('host') || '' + const isLocalhost = + host.includes('localhost') || host.includes('127.0.0.1') || host.includes('0.0.0.0') + const isHttps = req.secure || req.headers['x-forwarded-proto'] === 'https' + if (isLocalhost || isHttps) { - res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); - res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); - res.setHeader('Origin-Agent-Cluster', '?1'); + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin') + res.setHeader('Origin-Agent-Cluster', '?1') } - + // Content Security Policy (适用于web界面) if (req.path.startsWith('/web') || req.path === '/') { - res.setHeader('Content-Security-Policy', [ - 'default-src \'self\'', - 'script-src \'self\' \'unsafe-inline\' \'unsafe-eval\' https://unpkg.com https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://cdn.bootcdn.net', - 'style-src \'self\' \'unsafe-inline\' https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.bootcdn.net', - 'font-src \'self\' https://cdnjs.cloudflare.com https://cdn.bootcdn.net', - 'img-src \'self\' data:', - 'connect-src \'self\'', - 'frame-ancestors \'none\'', - 'base-uri \'self\'', - 'form-action \'self\'' - ].join('; ')); + res.setHeader( + 'Content-Security-Policy', + [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://cdn.bootcdn.net", + "style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.bootcdn.net", + "font-src 'self' https://cdnjs.cloudflare.com https://cdn.bootcdn.net", + "img-src 'self' data:", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'" + ].join('; ') + ) } - + // Strict Transport Security (HTTPS) if (req.secure || req.headers['x-forwarded-proto'] === 'https') { - res.setHeader('Strict-Transport-Security', 'max-age=15552000; includeSubDomains'); + res.setHeader('Strict-Transport-Security', 'max-age=15552000; includeSubDomains') } - + // 移除泄露服务器信息的头 - res.removeHeader('X-Powered-By'); - res.removeHeader('Server'); - + res.removeHeader('X-Powered-By') + res.removeHeader('Server') + // 防止信息泄露 - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - res.setHeader('Pragma', 'no-cache'); - res.setHeader('Expires', '0'); - - next(); -}; + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + res.setHeader('Pragma', 'no-cache') + res.setHeader('Expires', '0') + + next() +} // 🚨 错误处理中间件(增强版) const errorHandler = (error, req, res, _next) => { - const requestId = req.requestId || 'unknown'; - const isDevelopment = process.env.NODE_ENV === 'development'; - + const requestId = req.requestId || 'unknown' + const isDevelopment = process.env.NODE_ENV === 'development' + // 记录详细错误信息 logger.error(`💥 [${requestId}] Unhandled error:`, { error: error.message, @@ -585,155 +642,155 @@ const errorHandler = (error, req, res, _next) => { userAgent: req.get('User-Agent') || 'unknown', apiKey: req.apiKey ? req.apiKey.id : 'none', admin: req.admin ? req.admin.username : 'none' - }); - + }) + // 确定HTTP状态码 - let statusCode = 500; - let errorMessage = 'Internal Server Error'; - let userMessage = 'Something went wrong'; - + let statusCode = 500 + let errorMessage = 'Internal Server Error' + let userMessage = 'Something went wrong' + if (error.status && error.status >= 400 && error.status < 600) { - statusCode = error.status; + statusCode = error.status } - + // 根据错误类型提供友好的错误消息 switch (error.name) { case 'ValidationError': - statusCode = 400; - errorMessage = 'Validation Error'; - userMessage = 'Invalid input data'; - break; + statusCode = 400 + errorMessage = 'Validation Error' + userMessage = 'Invalid input data' + break case 'CastError': - statusCode = 400; - errorMessage = 'Cast Error'; - userMessage = 'Invalid data format'; - break; + statusCode = 400 + errorMessage = 'Cast Error' + userMessage = 'Invalid data format' + break case 'MongoError': case 'RedisError': - statusCode = 503; - errorMessage = 'Database Error'; - userMessage = 'Database temporarily unavailable'; - break; + statusCode = 503 + errorMessage = 'Database Error' + userMessage = 'Database temporarily unavailable' + break case 'TimeoutError': - statusCode = 408; - errorMessage = 'Request Timeout'; - userMessage = 'Request took too long to process'; - break; + statusCode = 408 + errorMessage = 'Request Timeout' + userMessage = 'Request took too long to process' + break default: if (error.message && !isDevelopment) { // 在生产环境中,只显示安全的错误消息 if (error.message.includes('ECONNREFUSED')) { - userMessage = 'Service temporarily unavailable'; + userMessage = 'Service temporarily unavailable' } else if (error.message.includes('timeout')) { - userMessage = 'Request timeout'; + userMessage = 'Request timeout' } } } - + // 设置响应头 - res.setHeader('X-Request-ID', requestId); - + res.setHeader('X-Request-ID', requestId) + // 构建错误响应 const errorResponse = { error: errorMessage, message: isDevelopment ? error.message : userMessage, requestId, timestamp: new Date().toISOString() - }; - + } + // 在开发环境中包含更多调试信息 if (isDevelopment) { - errorResponse.stack = error.stack; - errorResponse.url = req.originalUrl; - errorResponse.method = req.method; + errorResponse.stack = error.stack + errorResponse.url = req.originalUrl + errorResponse.method = req.method } - - res.status(statusCode).json(errorResponse); -}; + + res.status(statusCode).json(errorResponse) +} // 🌐 全局速率限制中间件(延迟初始化) -let rateLimiter = null; +let rateLimiter = null const getRateLimiter = () => { if (!rateLimiter) { try { - const client = redis.getClient(); + const client = redis.getClient() if (!client) { - logger.warn('⚠️ Redis client not available for rate limiter'); - return null; + logger.warn('⚠️ Redis client not available for rate limiter') + return null } - + rateLimiter = new RateLimiterRedis({ storeClient: client, keyPrefix: 'global_rate_limit', points: 1000, // 请求数量 duration: 900, // 15分钟 (900秒) - blockDuration: 900, // 阻塞时间15分钟 - }); - - logger.info('✅ Rate limiter initialized successfully'); + blockDuration: 900 // 阻塞时间15分钟 + }) + + logger.info('✅ Rate limiter initialized successfully') } catch (error) { - logger.warn('⚠️ Rate limiter initialization failed, using fallback', { error: error.message }); - return null; + logger.warn('⚠️ Rate limiter initialization failed, using fallback', { error: error.message }) + return null } } - return rateLimiter; -}; + return rateLimiter +} const globalRateLimit = async (req, res, next) => { // 跳过健康检查和内部请求 if (req.path === '/health' || req.path === '/api/health') { - return next(); + return next() } - - const limiter = getRateLimiter(); + + const limiter = getRateLimiter() if (!limiter) { // 如果Redis不可用,直接跳过速率限制 - return next(); + return next() } - - const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; - + + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + try { - await limiter.consume(clientIP); - next(); + await limiter.consume(clientIP) + return next() } catch (rejRes) { - const remainingPoints = rejRes.remainingPoints || 0; - const msBeforeNext = rejRes.msBeforeNext || 900000; - - logger.security(`🚦 Global rate limit exceeded for IP: ${clientIP}`); - + const remainingPoints = rejRes.remainingPoints || 0 + const msBeforeNext = rejRes.msBeforeNext || 900000 + + logger.security(`🚦 Global rate limit exceeded for IP: ${clientIP}`) + res.set({ 'Retry-After': Math.round(msBeforeNext / 1000) || 900, 'X-RateLimit-Limit': 1000, 'X-RateLimit-Remaining': remainingPoints, 'X-RateLimit-Reset': new Date(Date.now() + msBeforeNext).toISOString() - }); - - res.status(429).json({ + }) + + return res.status(429).json({ error: 'Too Many Requests', message: 'Too many requests from this IP, please try again later.', retryAfter: Math.round(msBeforeNext / 1000) - }); + }) } -}; +} // 📊 请求大小限制中间件 const requestSizeLimit = (req, res, next) => { - const maxSize = 10 * 1024 * 1024; // 10MB - const contentLength = parseInt(req.headers['content-length'] || '0'); - + const maxSize = 10 * 1024 * 1024 // 10MB + const contentLength = parseInt(req.headers['content-length'] || '0') + if (contentLength > maxSize) { - logger.security(`🚨 Request too large: ${contentLength} bytes from ${req.ip}`); + logger.security(`🚨 Request too large: ${contentLength} bytes from ${req.ip}`) return res.status(413).json({ error: 'Payload Too Large', message: 'Request body size exceeds limit', limit: '10MB' - }); + }) } - - next(); -}; + + return next() +} module.exports = { authenticateApiKey, @@ -744,4 +801,4 @@ module.exports = { errorHandler, globalRateLimit, requestSizeLimit -}; \ No newline at end of file +} diff --git a/src/models/redis.js b/src/models/redis.js index b27fdf9c..0f5a57ec 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1,38 +1,38 @@ -const Redis = require('ioredis'); -const config = require('../../config/config'); -const logger = require('../utils/logger'); +const Redis = require('ioredis') +const config = require('../../config/config') +const logger = require('../utils/logger') // 时区辅助函数 // 注意:这个函数的目的是获取某个时间点在目标时区的"本地"表示 // 例如:UTC时间 2025-07-30 01:00:00 在 UTC+8 时区表示为 2025-07-30 09:00:00 function getDateInTimezone(date = new Date()) { - const offset = config.system.timezoneOffset || 8; // 默认UTC+8 - + const offset = config.system.timezoneOffset || 8 // 默认UTC+8 + // 方法:创建一个偏移后的Date对象,使其getUTCXXX方法返回目标时区的值 // 这样我们可以用getUTCFullYear()等方法获取目标时区的年月日时分秒 - const offsetMs = offset * 3600000; // 时区偏移的毫秒数 - const adjustedTime = new Date(date.getTime() + offsetMs); - - return adjustedTime; + const offsetMs = offset * 3600000 // 时区偏移的毫秒数 + const adjustedTime = new Date(date.getTime() + offsetMs) + + return adjustedTime } // 获取配置时区的日期字符串 (YYYY-MM-DD) function getDateStringInTimezone(date = new Date()) { - const tzDate = getDateInTimezone(date); + const tzDate = getDateInTimezone(date) // 使用UTC方法获取偏移后的日期部分 - return `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}`; + return `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}` } // 获取配置时区的小时 (0-23) function getHourInTimezone(date = new Date()) { - const tzDate = getDateInTimezone(date); - return tzDate.getUTCHours(); + const tzDate = getDateInTimezone(date) + return tzDate.getUTCHours() } class RedisClient { constructor() { - this.client = null; - this.isConnected = false; + this.client = null + this.isConnected = false } async connect() { @@ -46,329 +46,349 @@ class RedisClient { maxRetriesPerRequest: config.redis.maxRetriesPerRequest, lazyConnect: config.redis.lazyConnect, tls: config.redis.enableTLS ? {} : false - }); + }) this.client.on('connect', () => { - this.isConnected = true; - logger.info('🔗 Redis connected successfully'); - }); + this.isConnected = true + logger.info('🔗 Redis connected successfully') + }) this.client.on('error', (err) => { - this.isConnected = false; - logger.error('❌ Redis connection error:', err); - }); + this.isConnected = false + logger.error('❌ Redis connection error:', err) + }) this.client.on('close', () => { - this.isConnected = false; - logger.warn('⚠️ Redis connection closed'); - }); + this.isConnected = false + logger.warn('⚠️ Redis connection closed') + }) - await this.client.connect(); - return this.client; + await this.client.connect() + return this.client } catch (error) { - logger.error('💥 Failed to connect to Redis:', error); - throw error; + logger.error('💥 Failed to connect to Redis:', error) + throw error } } async disconnect() { if (this.client) { - await this.client.quit(); - this.isConnected = false; - logger.info('👋 Redis disconnected'); + await this.client.quit() + this.isConnected = false + logger.info('👋 Redis disconnected') } } getClient() { if (!this.client || !this.isConnected) { - logger.warn('⚠️ Redis client is not connected'); - return null; + logger.warn('⚠️ Redis client is not connected') + return null } - return this.client; + return this.client } // 安全获取客户端(用于关键操作) getClientSafe() { if (!this.client || !this.isConnected) { - throw new Error('Redis client is not connected'); + throw new Error('Redis client is not connected') } - return this.client; + return this.client } // 🔑 API Key 相关操作 async setApiKey(keyId, keyData, hashedKey = null) { - const key = `apikey:${keyId}`; - const client = this.getClientSafe(); + const key = `apikey:${keyId}` + const client = this.getClientSafe() // 维护哈希映射表(用于快速查找) // hashedKey参数是实际的哈希值,用于建立映射 if (hashedKey) { - await client.hset('apikey:hash_map', hashedKey, keyId); + await client.hset('apikey:hash_map', hashedKey, keyId) } - await client.hset(key, keyData); - await client.expire(key, 86400 * 365); // 1年过期 + await client.hset(key, keyData) + await client.expire(key, 86400 * 365) // 1年过期 } async getApiKey(keyId) { - const key = `apikey:${keyId}`; - return await this.client.hgetall(key); + const key = `apikey:${keyId}` + return await this.client.hgetall(key) } async deleteApiKey(keyId) { - const key = `apikey:${keyId}`; + const key = `apikey:${keyId}` // 获取要删除的API Key哈希值,以便从映射表中移除 - const keyData = await this.client.hgetall(key); + const keyData = await this.client.hgetall(key) if (keyData && keyData.apiKey) { // keyData.apiKey现在存储的是哈希值,直接从映射表删除 - await this.client.hdel('apikey:hash_map', keyData.apiKey); + await this.client.hdel('apikey:hash_map', keyData.apiKey) } - return await this.client.del(key); + return await this.client.del(key) } async getAllApiKeys() { - const keys = await this.client.keys('apikey:*'); - const apiKeys = []; + const keys = await this.client.keys('apikey:*') + const apiKeys = [] for (const key of keys) { // 过滤掉hash_map,它不是真正的API Key if (key === 'apikey:hash_map') { - continue; + continue } - const keyData = await this.client.hgetall(key); + const keyData = await this.client.hgetall(key) if (keyData && Object.keys(keyData).length > 0) { - apiKeys.push({ id: key.replace('apikey:', ''), ...keyData }); + apiKeys.push({ id: key.replace('apikey:', ''), ...keyData }) } } - return apiKeys; + return apiKeys } // 🔍 通过哈希值查找API Key(性能优化) async findApiKeyByHash(hashedKey) { // 使用反向映射表:hash -> keyId - const keyId = await this.client.hget('apikey:hash_map', hashedKey); + const keyId = await this.client.hget('apikey:hash_map', hashedKey) if (!keyId) { - return null; + return null } - const keyData = await this.client.hgetall(`apikey:${keyId}`); + const keyData = await this.client.hgetall(`apikey:${keyId}`) if (keyData && Object.keys(keyData).length > 0) { - return { id: keyId, ...keyData }; + return { id: keyId, ...keyData } } // 如果数据不存在,清理映射表 - await this.client.hdel('apikey:hash_map', hashedKey); - return null; + await this.client.hdel('apikey:hash_map', hashedKey) + return null } // 📊 使用统计相关操作(支持缓存token统计和模型信息) // 标准化模型名称,用于统计聚合 _normalizeModelName(model) { - if (!model || model === 'unknown') return 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; + 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$/, ''); + return model.replace(/-v\d+:\d+$|:latest$/, '') } - async incrementTokenUsage(keyId, tokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') { - const key = `usage:${keyId}`; - const now = new Date(); - const today = getDateStringInTimezone(now); - const tzDate = getDateInTimezone(now); - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`; - const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}`; // 新增小时级别 - - const daily = `usage:daily:${keyId}:${today}`; - const monthly = `usage:monthly:${keyId}:${currentMonth}`; - const hourly = `usage:hourly:${keyId}:${currentHour}`; // 新增小时级别key + async incrementTokenUsage( + keyId, + tokens, + inputTokens = 0, + outputTokens = 0, + cacheCreateTokens = 0, + cacheReadTokens = 0, + model = 'unknown' + ) { + const key = `usage:${keyId}` + const now = new Date() + const today = getDateStringInTimezone(now) + const tzDate = getDateInTimezone(now) + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}` // 新增小时级别 + + const daily = `usage:daily:${keyId}:${today}` + const monthly = `usage:monthly:${keyId}:${currentMonth}` + const hourly = `usage:hourly:${keyId}:${currentHour}` // 新增小时级别key // 标准化模型名用于统计聚合 - const normalizedModel = this._normalizeModelName(model); + const normalizedModel = this._normalizeModelName(model) // 按模型统计的键 - const modelDaily = `usage:model:daily:${normalizedModel}:${today}`; - const modelMonthly = `usage:model:monthly:${normalizedModel}:${currentMonth}`; - const modelHourly = `usage:model:hourly:${normalizedModel}:${currentHour}`; // 新增模型小时级别 + const modelDaily = `usage:model:daily:${normalizedModel}:${today}` + const modelMonthly = `usage:model:monthly:${normalizedModel}:${currentMonth}` + const modelHourly = `usage:model:hourly:${normalizedModel}:${currentHour}` // 新增模型小时级别 // API Key级别的模型统计 - const keyModelDaily = `usage:${keyId}:model:daily:${normalizedModel}:${today}`; - const keyModelMonthly = `usage:${keyId}:model:monthly:${normalizedModel}:${currentMonth}`; - const keyModelHourly = `usage:${keyId}:model:hourly:${normalizedModel}:${currentHour}`; // 新增API Key模型小时级别 + const keyModelDaily = `usage:${keyId}:model:daily:${normalizedModel}:${today}` + const keyModelMonthly = `usage:${keyId}:model:monthly:${normalizedModel}:${currentMonth}` + const keyModelHourly = `usage:${keyId}:model:hourly:${normalizedModel}:${currentHour}` // 新增API Key模型小时级别 // 新增:系统级分钟统计 - const minuteTimestamp = Math.floor(now.getTime() / 60000); - const systemMinuteKey = `system:metrics:minute:${minuteTimestamp}`; + const minuteTimestamp = Math.floor(now.getTime() / 60000) + const systemMinuteKey = `system:metrics:minute:${minuteTimestamp}` // 智能处理输入输出token分配 - const finalInputTokens = inputTokens || 0; - const finalOutputTokens = outputTokens || (finalInputTokens > 0 ? 0 : tokens); - const finalCacheCreateTokens = cacheCreateTokens || 0; - const finalCacheReadTokens = cacheReadTokens || 0; + const finalInputTokens = inputTokens || 0 + const finalOutputTokens = outputTokens || (finalInputTokens > 0 ? 0 : tokens) + const finalCacheCreateTokens = cacheCreateTokens || 0 + const finalCacheReadTokens = cacheReadTokens || 0 // 重新计算真实的总token数(包括缓存token) - const totalTokens = finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens; + const totalTokens = + finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens // 核心token(不包括缓存)- 用于与历史数据兼容 - const coreTokens = finalInputTokens + finalOutputTokens; + const coreTokens = finalInputTokens + finalOutputTokens // 使用Pipeline优化性能 - const pipeline = this.client.pipeline(); - + const pipeline = this.client.pipeline() + // 现有的统计保持不变 // 核心token统计(保持向后兼容) - pipeline.hincrby(key, 'totalTokens', coreTokens); - pipeline.hincrby(key, 'totalInputTokens', finalInputTokens); - pipeline.hincrby(key, 'totalOutputTokens', finalOutputTokens); + pipeline.hincrby(key, 'totalTokens', coreTokens) + pipeline.hincrby(key, 'totalInputTokens', finalInputTokens) + pipeline.hincrby(key, 'totalOutputTokens', finalOutputTokens) // 缓存token统计(新增) - pipeline.hincrby(key, 'totalCacheCreateTokens', finalCacheCreateTokens); - pipeline.hincrby(key, 'totalCacheReadTokens', finalCacheReadTokens); - pipeline.hincrby(key, 'totalAllTokens', totalTokens); // 包含所有类型的总token + pipeline.hincrby(key, 'totalCacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(key, 'totalCacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(key, 'totalAllTokens', totalTokens) // 包含所有类型的总token // 请求计数 - pipeline.hincrby(key, 'totalRequests', 1); - + pipeline.hincrby(key, 'totalRequests', 1) + // 每日统计 - pipeline.hincrby(daily, 'tokens', coreTokens); - pipeline.hincrby(daily, 'inputTokens', finalInputTokens); - pipeline.hincrby(daily, 'outputTokens', finalOutputTokens); - pipeline.hincrby(daily, 'cacheCreateTokens', finalCacheCreateTokens); - pipeline.hincrby(daily, 'cacheReadTokens', finalCacheReadTokens); - pipeline.hincrby(daily, 'allTokens', totalTokens); - pipeline.hincrby(daily, 'requests', 1); - + pipeline.hincrby(daily, 'tokens', coreTokens) + pipeline.hincrby(daily, 'inputTokens', finalInputTokens) + pipeline.hincrby(daily, 'outputTokens', finalOutputTokens) + pipeline.hincrby(daily, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(daily, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(daily, 'allTokens', totalTokens) + pipeline.hincrby(daily, 'requests', 1) + // 每月统计 - pipeline.hincrby(monthly, 'tokens', coreTokens); - pipeline.hincrby(monthly, 'inputTokens', finalInputTokens); - pipeline.hincrby(monthly, 'outputTokens', finalOutputTokens); - pipeline.hincrby(monthly, 'cacheCreateTokens', finalCacheCreateTokens); - pipeline.hincrby(monthly, 'cacheReadTokens', finalCacheReadTokens); - pipeline.hincrby(monthly, 'allTokens', totalTokens); - pipeline.hincrby(monthly, 'requests', 1); - + pipeline.hincrby(monthly, 'tokens', coreTokens) + pipeline.hincrby(monthly, 'inputTokens', finalInputTokens) + pipeline.hincrby(monthly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(monthly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(monthly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(monthly, 'allTokens', totalTokens) + pipeline.hincrby(monthly, 'requests', 1) + // 按模型统计 - 每日 - pipeline.hincrby(modelDaily, 'inputTokens', finalInputTokens); - pipeline.hincrby(modelDaily, 'outputTokens', finalOutputTokens); - pipeline.hincrby(modelDaily, 'cacheCreateTokens', finalCacheCreateTokens); - pipeline.hincrby(modelDaily, 'cacheReadTokens', finalCacheReadTokens); - pipeline.hincrby(modelDaily, 'allTokens', totalTokens); - pipeline.hincrby(modelDaily, 'requests', 1); - + pipeline.hincrby(modelDaily, 'inputTokens', finalInputTokens) + pipeline.hincrby(modelDaily, 'outputTokens', finalOutputTokens) + pipeline.hincrby(modelDaily, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(modelDaily, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(modelDaily, 'allTokens', totalTokens) + pipeline.hincrby(modelDaily, 'requests', 1) + // 按模型统计 - 每月 - pipeline.hincrby(modelMonthly, 'inputTokens', finalInputTokens); - pipeline.hincrby(modelMonthly, 'outputTokens', finalOutputTokens); - pipeline.hincrby(modelMonthly, 'cacheCreateTokens', finalCacheCreateTokens); - pipeline.hincrby(modelMonthly, 'cacheReadTokens', finalCacheReadTokens); - pipeline.hincrby(modelMonthly, 'allTokens', totalTokens); - pipeline.hincrby(modelMonthly, 'requests', 1); - + pipeline.hincrby(modelMonthly, 'inputTokens', finalInputTokens) + pipeline.hincrby(modelMonthly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(modelMonthly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(modelMonthly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(modelMonthly, 'allTokens', totalTokens) + pipeline.hincrby(modelMonthly, 'requests', 1) + // API Key级别的模型统计 - 每日 - pipeline.hincrby(keyModelDaily, 'inputTokens', finalInputTokens); - pipeline.hincrby(keyModelDaily, 'outputTokens', finalOutputTokens); - pipeline.hincrby(keyModelDaily, 'cacheCreateTokens', finalCacheCreateTokens); - pipeline.hincrby(keyModelDaily, 'cacheReadTokens', finalCacheReadTokens); - pipeline.hincrby(keyModelDaily, 'allTokens', totalTokens); - pipeline.hincrby(keyModelDaily, 'requests', 1); - + pipeline.hincrby(keyModelDaily, 'inputTokens', finalInputTokens) + pipeline.hincrby(keyModelDaily, 'outputTokens', finalOutputTokens) + pipeline.hincrby(keyModelDaily, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(keyModelDaily, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(keyModelDaily, 'allTokens', totalTokens) + pipeline.hincrby(keyModelDaily, 'requests', 1) + // API Key级别的模型统计 - 每月 - pipeline.hincrby(keyModelMonthly, 'inputTokens', finalInputTokens); - pipeline.hincrby(keyModelMonthly, 'outputTokens', finalOutputTokens); - pipeline.hincrby(keyModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens); - pipeline.hincrby(keyModelMonthly, 'cacheReadTokens', finalCacheReadTokens); - pipeline.hincrby(keyModelMonthly, 'allTokens', totalTokens); - pipeline.hincrby(keyModelMonthly, 'requests', 1); - + pipeline.hincrby(keyModelMonthly, 'inputTokens', finalInputTokens) + pipeline.hincrby(keyModelMonthly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(keyModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(keyModelMonthly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(keyModelMonthly, 'allTokens', totalTokens) + pipeline.hincrby(keyModelMonthly, 'requests', 1) + // 小时级别统计 - pipeline.hincrby(hourly, 'tokens', coreTokens); - pipeline.hincrby(hourly, 'inputTokens', finalInputTokens); - pipeline.hincrby(hourly, 'outputTokens', finalOutputTokens); - pipeline.hincrby(hourly, 'cacheCreateTokens', finalCacheCreateTokens); - pipeline.hincrby(hourly, 'cacheReadTokens', finalCacheReadTokens); - pipeline.hincrby(hourly, 'allTokens', totalTokens); - pipeline.hincrby(hourly, 'requests', 1); - + pipeline.hincrby(hourly, 'tokens', coreTokens) + pipeline.hincrby(hourly, 'inputTokens', finalInputTokens) + pipeline.hincrby(hourly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(hourly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(hourly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(hourly, 'allTokens', totalTokens) + pipeline.hincrby(hourly, 'requests', 1) + // 按模型统计 - 每小时 - pipeline.hincrby(modelHourly, 'inputTokens', finalInputTokens); - pipeline.hincrby(modelHourly, 'outputTokens', finalOutputTokens); - pipeline.hincrby(modelHourly, 'cacheCreateTokens', finalCacheCreateTokens); - pipeline.hincrby(modelHourly, 'cacheReadTokens', finalCacheReadTokens); - pipeline.hincrby(modelHourly, 'allTokens', totalTokens); - pipeline.hincrby(modelHourly, 'requests', 1); - + pipeline.hincrby(modelHourly, 'inputTokens', finalInputTokens) + pipeline.hincrby(modelHourly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(modelHourly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(modelHourly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(modelHourly, 'allTokens', totalTokens) + pipeline.hincrby(modelHourly, 'requests', 1) + // API Key级别的模型统计 - 每小时 - pipeline.hincrby(keyModelHourly, 'inputTokens', finalInputTokens); - pipeline.hincrby(keyModelHourly, 'outputTokens', finalOutputTokens); - pipeline.hincrby(keyModelHourly, 'cacheCreateTokens', finalCacheCreateTokens); - pipeline.hincrby(keyModelHourly, 'cacheReadTokens', finalCacheReadTokens); - pipeline.hincrby(keyModelHourly, 'allTokens', totalTokens); - pipeline.hincrby(keyModelHourly, 'requests', 1); - + pipeline.hincrby(keyModelHourly, 'inputTokens', finalInputTokens) + pipeline.hincrby(keyModelHourly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(keyModelHourly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(keyModelHourly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(keyModelHourly, 'allTokens', totalTokens) + pipeline.hincrby(keyModelHourly, 'requests', 1) + // 新增:系统级分钟统计 - pipeline.hincrby(systemMinuteKey, 'requests', 1); - pipeline.hincrby(systemMinuteKey, 'totalTokens', totalTokens); - pipeline.hincrby(systemMinuteKey, 'inputTokens', finalInputTokens); - pipeline.hincrby(systemMinuteKey, 'outputTokens', finalOutputTokens); - pipeline.hincrby(systemMinuteKey, 'cacheCreateTokens', finalCacheCreateTokens); - pipeline.hincrby(systemMinuteKey, 'cacheReadTokens', finalCacheReadTokens); - + pipeline.hincrby(systemMinuteKey, 'requests', 1) + pipeline.hincrby(systemMinuteKey, 'totalTokens', totalTokens) + pipeline.hincrby(systemMinuteKey, 'inputTokens', finalInputTokens) + pipeline.hincrby(systemMinuteKey, 'outputTokens', finalOutputTokens) + pipeline.hincrby(systemMinuteKey, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(systemMinuteKey, 'cacheReadTokens', finalCacheReadTokens) + // 设置过期时间 - pipeline.expire(daily, 86400 * 32); // 32天过期 - pipeline.expire(monthly, 86400 * 365); // 1年过期 - pipeline.expire(hourly, 86400 * 7); // 小时统计7天过期 - pipeline.expire(modelDaily, 86400 * 32); // 模型每日统计32天过期 - pipeline.expire(modelMonthly, 86400 * 365); // 模型每月统计1年过期 - pipeline.expire(modelHourly, 86400 * 7); // 模型小时统计7天过期 - pipeline.expire(keyModelDaily, 86400 * 32); // API Key模型每日统计32天过期 - pipeline.expire(keyModelMonthly, 86400 * 365); // API Key模型每月统计1年过期 - pipeline.expire(keyModelHourly, 86400 * 7); // API Key模型小时统计7天过期 - + pipeline.expire(daily, 86400 * 32) // 32天过期 + pipeline.expire(monthly, 86400 * 365) // 1年过期 + pipeline.expire(hourly, 86400 * 7) // 小时统计7天过期 + pipeline.expire(modelDaily, 86400 * 32) // 模型每日统计32天过期 + pipeline.expire(modelMonthly, 86400 * 365) // 模型每月统计1年过期 + pipeline.expire(modelHourly, 86400 * 7) // 模型小时统计7天过期 + pipeline.expire(keyModelDaily, 86400 * 32) // API Key模型每日统计32天过期 + pipeline.expire(keyModelMonthly, 86400 * 365) // API Key模型每月统计1年过期 + pipeline.expire(keyModelHourly, 86400 * 7) // API Key模型小时统计7天过期 + // 系统级分钟统计的过期时间(窗口时间的2倍) - const config = require('../../config/config'); - const metricsWindow = config.system.metricsWindow; - pipeline.expire(systemMinuteKey, metricsWindow * 60 * 2); - + const configLocal = require('../../config/config') + const { metricsWindow } = configLocal.system + pipeline.expire(systemMinuteKey, metricsWindow * 60 * 2) + // 执行Pipeline - await pipeline.exec(); + await pipeline.exec() } // 📊 记录账户级别的使用统计 - async incrementAccountUsage(accountId, totalTokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') { - const now = new Date(); - const today = getDateStringInTimezone(now); - const tzDate = getDateInTimezone(now); - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`; - const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}`; - + async incrementAccountUsage( + accountId, + totalTokens, + inputTokens = 0, + outputTokens = 0, + cacheCreateTokens = 0, + cacheReadTokens = 0, + model = 'unknown' + ) { + const now = new Date() + const today = getDateStringInTimezone(now) + const tzDate = getDateInTimezone(now) + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}` + // 账户级别统计的键 - const accountKey = `account_usage:${accountId}`; - const accountDaily = `account_usage:daily:${accountId}:${today}`; - const accountMonthly = `account_usage:monthly:${accountId}:${currentMonth}`; - const accountHourly = `account_usage:hourly:${accountId}:${currentHour}`; - + const accountKey = `account_usage:${accountId}` + const accountDaily = `account_usage:daily:${accountId}:${today}` + const accountMonthly = `account_usage:monthly:${accountId}:${currentMonth}` + const accountHourly = `account_usage:hourly:${accountId}:${currentHour}` + // 标准化模型名用于统计聚合 - const normalizedModel = this._normalizeModelName(model); - + const normalizedModel = this._normalizeModelName(model) + // 账户按模型统计的键 - const accountModelDaily = `account_usage:model:daily:${accountId}:${normalizedModel}:${today}`; - const accountModelMonthly = `account_usage:model:monthly:${accountId}:${normalizedModel}:${currentMonth}`; - const accountModelHourly = `account_usage:model:hourly:${accountId}:${normalizedModel}:${currentHour}`; - + const accountModelDaily = `account_usage:model:daily:${accountId}:${normalizedModel}:${today}` + const accountModelMonthly = `account_usage:model:monthly:${accountId}:${normalizedModel}:${currentMonth}` + const accountModelHourly = `account_usage:model:hourly:${accountId}:${normalizedModel}:${currentHour}` + // 处理token分配 - const finalInputTokens = inputTokens || 0; - const finalOutputTokens = outputTokens || 0; - const finalCacheCreateTokens = cacheCreateTokens || 0; - const finalCacheReadTokens = cacheReadTokens || 0; - const actualTotalTokens = finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens; - const coreTokens = finalInputTokens + finalOutputTokens; + const finalInputTokens = inputTokens || 0 + const finalOutputTokens = outputTokens || 0 + const finalCacheCreateTokens = cacheCreateTokens || 0 + const finalCacheReadTokens = cacheReadTokens || 0 + const actualTotalTokens = + finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens + const coreTokens = finalInputTokens + finalOutputTokens await Promise.all([ // 账户总体统计 @@ -379,7 +399,7 @@ class RedisClient { this.client.hincrby(accountKey, 'totalCacheReadTokens', finalCacheReadTokens), this.client.hincrby(accountKey, 'totalAllTokens', actualTotalTokens), this.client.hincrby(accountKey, 'totalRequests', 1), - + // 账户每日统计 this.client.hincrby(accountDaily, 'tokens', coreTokens), this.client.hincrby(accountDaily, 'inputTokens', finalInputTokens), @@ -388,7 +408,7 @@ class RedisClient { this.client.hincrby(accountDaily, 'cacheReadTokens', finalCacheReadTokens), this.client.hincrby(accountDaily, 'allTokens', actualTotalTokens), this.client.hincrby(accountDaily, 'requests', 1), - + // 账户每月统计 this.client.hincrby(accountMonthly, 'tokens', coreTokens), this.client.hincrby(accountMonthly, 'inputTokens', finalInputTokens), @@ -397,7 +417,7 @@ class RedisClient { this.client.hincrby(accountMonthly, 'cacheReadTokens', finalCacheReadTokens), this.client.hincrby(accountMonthly, 'allTokens', actualTotalTokens), this.client.hincrby(accountMonthly, 'requests', 1), - + // 账户每小时统计 this.client.hincrby(accountHourly, 'tokens', coreTokens), this.client.hincrby(accountHourly, 'inputTokens', finalInputTokens), @@ -406,7 +426,7 @@ class RedisClient { this.client.hincrby(accountHourly, 'cacheReadTokens', finalCacheReadTokens), this.client.hincrby(accountHourly, 'allTokens', actualTotalTokens), this.client.hincrby(accountHourly, 'requests', 1), - + // 账户按模型统计 - 每日 this.client.hincrby(accountModelDaily, 'inputTokens', finalInputTokens), this.client.hincrby(accountModelDaily, 'outputTokens', finalOutputTokens), @@ -414,7 +434,7 @@ class RedisClient { this.client.hincrby(accountModelDaily, 'cacheReadTokens', finalCacheReadTokens), this.client.hincrby(accountModelDaily, 'allTokens', actualTotalTokens), this.client.hincrby(accountModelDaily, 'requests', 1), - + // 账户按模型统计 - 每月 this.client.hincrby(accountModelMonthly, 'inputTokens', finalInputTokens), this.client.hincrby(accountModelMonthly, 'outputTokens', finalOutputTokens), @@ -422,7 +442,7 @@ class RedisClient { this.client.hincrby(accountModelMonthly, 'cacheReadTokens', finalCacheReadTokens), this.client.hincrby(accountModelMonthly, 'allTokens', actualTotalTokens), this.client.hincrby(accountModelMonthly, 'requests', 1), - + // 账户按模型统计 - 每小时 this.client.hincrby(accountModelHourly, 'inputTokens', finalInputTokens), this.client.hincrby(accountModelHourly, 'outputTokens', finalOutputTokens), @@ -430,7 +450,7 @@ class RedisClient { this.client.hincrby(accountModelHourly, 'cacheReadTokens', finalCacheReadTokens), this.client.hincrby(accountModelHourly, 'allTokens', actualTotalTokens), this.client.hincrby(accountModelHourly, 'requests', 1), - + // 设置过期时间 this.client.expire(accountDaily, 86400 * 32), // 32天过期 this.client.expire(accountMonthly, 86400 * 365), // 1年过期 @@ -438,65 +458,68 @@ class RedisClient { this.client.expire(accountModelDaily, 86400 * 32), // 32天过期 this.client.expire(accountModelMonthly, 86400 * 365), // 1年过期 this.client.expire(accountModelHourly, 86400 * 7) // 7天过期 - ]); + ]) } async getUsageStats(keyId) { - const totalKey = `usage:${keyId}`; - const today = getDateStringInTimezone(); - const dailyKey = `usage:daily:${keyId}:${today}`; - const tzDate = getDateInTimezone(); - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`; - const monthlyKey = `usage:monthly:${keyId}:${currentMonth}`; + const totalKey = `usage:${keyId}` + const today = getDateStringInTimezone() + const dailyKey = `usage:daily:${keyId}:${today}` + const tzDate = getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const monthlyKey = `usage:monthly:${keyId}:${currentMonth}` const [total, daily, monthly] = await Promise.all([ this.client.hgetall(totalKey), this.client.hgetall(dailyKey), this.client.hgetall(monthlyKey) - ]); + ]) // 获取API Key的创建时间来计算平均值 - const keyData = await this.client.hgetall(`apikey:${keyId}`); - const createdAt = keyData.createdAt ? new Date(keyData.createdAt) : new Date(); - const now = new Date(); - const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24))); + const keyData = await this.client.hgetall(`apikey:${keyId}`) + const createdAt = keyData.createdAt ? new Date(keyData.createdAt) : new Date() + const now = new Date() + const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24))) - const totalTokens = parseInt(total.totalTokens) || 0; - const totalRequests = parseInt(total.totalRequests) || 0; + const totalTokens = parseInt(total.totalTokens) || 0 + const totalRequests = parseInt(total.totalRequests) || 0 // 计算平均RPM (requests per minute) 和 TPM (tokens per minute) - const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60); - const avgRPM = totalRequests / totalMinutes; - const avgTPM = totalTokens / totalMinutes; + const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60) + const avgRPM = totalRequests / totalMinutes + const avgTPM = totalTokens / totalMinutes // 处理旧数据兼容性(支持缓存token) const handleLegacyData = (data) => { // 优先使用total*字段(存储时使用的字段) - const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0; - const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0; - const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0; - const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0; + const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0 + const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0 + const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0 // 新增缓存token字段 - const cacheCreateTokens = parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0; - const cacheReadTokens = parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0; - const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0; + const cacheCreateTokens = + parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = + parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0 - const totalFromSeparate = inputTokens + outputTokens; + const totalFromSeparate = inputTokens + outputTokens // 计算实际的总tokens(包含所有类型) - const actualAllTokens = allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens); + const actualAllTokens = + allTokens || inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens if (totalFromSeparate === 0 && tokens > 0) { // 旧数据:没有输入输出分离 return { - tokens: tokens, // 保持兼容性,但统一使用allTokens + tokens, // 保持兼容性,但统一使用allTokens inputTokens: Math.round(tokens * 0.3), // 假设30%为输入 outputTokens: Math.round(tokens * 0.7), // 假设70%为输出 cacheCreateTokens: 0, // 旧数据没有缓存token cacheReadTokens: 0, allTokens: tokens, // 对于旧数据,allTokens等于tokens requests - }; + } } else { // 新数据或无数据 - 统一使用allTokens作为tokens的值 return { @@ -507,13 +530,13 @@ class RedisClient { cacheReadTokens, allTokens: actualAllTokens, requests - }; + } } - }; + } - const totalData = handleLegacyData(total); - const dailyData = handleLegacyData(daily); - const monthlyData = handleLegacyData(monthly); + const totalData = handleLegacyData(total) + const dailyData = handleLegacyData(daily) + const monthlyData = handleLegacyData(monthly) return { total: totalData, @@ -525,33 +548,37 @@ class RedisClient { dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100, dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100 } - }; + } } // 💰 获取当日费用 async getDailyCost(keyId) { - const today = getDateStringInTimezone(); - const costKey = `usage:cost:daily:${keyId}:${today}`; - const cost = await this.client.get(costKey); - const result = parseFloat(cost || 0); - logger.debug(`💰 Getting daily cost for ${keyId}, date: ${today}, key: ${costKey}, value: ${cost}, result: ${result}`); - return result; + const today = getDateStringInTimezone() + const costKey = `usage:cost:daily:${keyId}:${today}` + const cost = await this.client.get(costKey) + const result = parseFloat(cost || 0) + logger.debug( + `💰 Getting daily cost for ${keyId}, date: ${today}, key: ${costKey}, value: ${cost}, result: ${result}` + ) + return result } // 💰 增加当日费用 async incrementDailyCost(keyId, amount) { - const today = getDateStringInTimezone(); - const tzDate = getDateInTimezone(); - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`; - const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}`; - - const dailyKey = `usage:cost:daily:${keyId}:${today}`; - const monthlyKey = `usage:cost:monthly:${keyId}:${currentMonth}`; - const hourlyKey = `usage:cost:hourly:${keyId}:${currentHour}`; - const totalKey = `usage:cost:total:${keyId}`; - - logger.debug(`💰 Incrementing cost for ${keyId}, amount: $${amount}, date: ${today}, dailyKey: ${dailyKey}`); - + const today = getDateStringInTimezone() + const tzDate = getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}` + + const dailyKey = `usage:cost:daily:${keyId}:${today}` + const monthlyKey = `usage:cost:monthly:${keyId}:${currentMonth}` + const hourlyKey = `usage:cost:hourly:${keyId}:${currentHour}` + const totalKey = `usage:cost:total:${keyId}` + + logger.debug( + `💰 Incrementing cost for ${keyId}, amount: $${amount}, date: ${today}, dailyKey: ${dailyKey}` + ) + const results = await Promise.all([ this.client.incrbyfloat(dailyKey, amount), this.client.incrbyfloat(monthlyKey, amount), @@ -561,91 +588,94 @@ class RedisClient { this.client.expire(dailyKey, 86400 * 30), // 30天 this.client.expire(monthlyKey, 86400 * 90), // 90天 this.client.expire(hourlyKey, 86400 * 7) // 7天 - ]); - - logger.debug(`💰 Cost incremented successfully, new daily total: $${results[0]}`); + ]) + + logger.debug(`💰 Cost incremented successfully, new daily total: $${results[0]}`) } // 💰 获取费用统计 async getCostStats(keyId) { - const today = getDateStringInTimezone(); - const tzDate = getDateInTimezone(); - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`; - const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}`; - + const today = getDateStringInTimezone() + const tzDate = getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}` + const [daily, monthly, hourly, total] = await Promise.all([ this.client.get(`usage:cost:daily:${keyId}:${today}`), this.client.get(`usage:cost:monthly:${keyId}:${currentMonth}`), this.client.get(`usage:cost:hourly:${keyId}:${currentHour}`), this.client.get(`usage:cost:total:${keyId}`) - ]); - + ]) + return { daily: parseFloat(daily || 0), monthly: parseFloat(monthly || 0), hourly: parseFloat(hourly || 0), total: parseFloat(total || 0) - }; + } } // 📊 获取账户使用统计 async getAccountUsageStats(accountId) { - const accountKey = `account_usage:${accountId}`; - const today = getDateStringInTimezone(); - const accountDailyKey = `account_usage:daily:${accountId}:${today}`; - const tzDate = getDateInTimezone(); - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`; - const accountMonthlyKey = `account_usage:monthly:${accountId}:${currentMonth}`; + const accountKey = `account_usage:${accountId}` + const today = getDateStringInTimezone() + const accountDailyKey = `account_usage:daily:${accountId}:${today}` + const tzDate = getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const accountMonthlyKey = `account_usage:monthly:${accountId}:${currentMonth}` const [total, daily, monthly] = await Promise.all([ this.client.hgetall(accountKey), this.client.hgetall(accountDailyKey), this.client.hgetall(accountMonthlyKey) - ]); + ]) // 获取账户创建时间来计算平均值 - const accountData = await this.client.hgetall(`claude_account:${accountId}`); - const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date(); - const now = new Date(); - const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24))); + const accountData = await this.client.hgetall(`claude_account:${accountId}`) + const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date() + const now = new Date() + const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24))) - const totalTokens = parseInt(total.totalTokens) || 0; - const totalRequests = parseInt(total.totalRequests) || 0; + const totalTokens = parseInt(total.totalTokens) || 0 + const totalRequests = parseInt(total.totalRequests) || 0 // 计算平均RPM和TPM - const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60); - const avgRPM = totalRequests / totalMinutes; - const avgTPM = totalTokens / totalMinutes; + const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60) + const avgRPM = totalRequests / totalMinutes + const avgTPM = totalTokens / totalMinutes // 处理账户统计数据 const handleAccountData = (data) => { - const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0; - const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0; - const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0; - const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0; - const cacheCreateTokens = parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0; - const cacheReadTokens = parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0; - const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0; + const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0 + const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0 + const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0 + const cacheCreateTokens = + parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = + parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0 - const actualAllTokens = allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens); + const actualAllTokens = + allTokens || inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens return { - tokens: tokens, - inputTokens: inputTokens, - outputTokens: outputTokens, - cacheCreateTokens: cacheCreateTokens, - cacheReadTokens: cacheReadTokens, + tokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, allTokens: actualAllTokens, - requests: requests - }; - }; + requests + } + } - const totalData = handleAccountData(total); - const dailyData = handleAccountData(daily); - const monthlyData = handleAccountData(monthly); + const totalData = handleAccountData(total) + const dailyData = handleAccountData(daily) + const monthlyData = handleAccountData(monthly) return { - accountId: accountId, + accountId, total: totalData, daily: dailyData, monthly: monthlyData, @@ -655,22 +685,22 @@ class RedisClient { dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100, dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100 } - }; + } } // 📈 获取所有账户的使用统计 async getAllAccountsUsageStats() { try { // 获取所有Claude账户 - const accountKeys = await this.client.keys('claude_account:*'); - const accountStats = []; - + const accountKeys = await this.client.keys('claude_account:*') + const accountStats = [] + for (const accountKey of accountKeys) { - const accountId = accountKey.replace('claude_account:', ''); - const accountData = await this.client.hgetall(accountKey); - + const accountId = accountKey.replace('claude_account:', '') + const accountData = await this.client.hgetall(accountKey) + if (accountData.name) { - const stats = await this.getAccountUsageStats(accountId); + const stats = await this.getAccountUsageStats(accountId) accountStats.push({ id: accountId, name: accountData.name, @@ -678,267 +708,271 @@ class RedisClient { status: accountData.status || 'unknown', isActive: accountData.isActive === 'true', ...stats - }); + }) } } - + // 按当日token使用量排序 - accountStats.sort((a, b) => (b.daily.allTokens || 0) - (a.daily.allTokens || 0)); - - return accountStats; + accountStats.sort((a, b) => (b.daily.allTokens || 0) - (a.daily.allTokens || 0)) + + return accountStats } catch (error) { - logger.error('❌ Failed to get all accounts usage stats:', error); - return []; + logger.error('❌ Failed to get all accounts usage stats:', error) + return [] } } // 🧹 清空所有API Key的使用统计数据 async resetAllUsageStats() { - const client = this.getClientSafe(); + const client = this.getClientSafe() const stats = { deletedKeys: 0, deletedDailyKeys: 0, deletedMonthlyKeys: 0, resetApiKeys: 0 - }; + } try { // 获取所有API Key ID - const apiKeyIds = []; - const apiKeyKeys = await client.keys('apikey:*'); + const apiKeyIds = [] + const apiKeyKeys = await client.keys('apikey:*') for (const key of apiKeyKeys) { - if (key === 'apikey:hash_map') continue; // 跳过哈希映射表 - const keyId = key.replace('apikey:', ''); - apiKeyIds.push(keyId); + if (key === 'apikey:hash_map') { + continue + } // 跳过哈希映射表 + const keyId = key.replace('apikey:', '') + apiKeyIds.push(keyId) } // 清空每个API Key的使用统计 for (const keyId of apiKeyIds) { // 删除总体使用统计 - const usageKey = `usage:${keyId}`; - const deleted = await client.del(usageKey); + const usageKey = `usage:${keyId}` + const deleted = await client.del(usageKey) if (deleted > 0) { - stats.deletedKeys++; + stats.deletedKeys++ } // 删除该API Key的每日统计(使用精确的keyId匹配) - const dailyKeys = await client.keys(`usage:daily:${keyId}:*`); + const dailyKeys = await client.keys(`usage:daily:${keyId}:*`) if (dailyKeys.length > 0) { - await client.del(...dailyKeys); - stats.deletedDailyKeys += dailyKeys.length; + await client.del(...dailyKeys) + stats.deletedDailyKeys += dailyKeys.length } // 删除该API Key的每月统计(使用精确的keyId匹配) - const monthlyKeys = await client.keys(`usage:monthly:${keyId}:*`); + const monthlyKeys = await client.keys(`usage:monthly:${keyId}:*`) if (monthlyKeys.length > 0) { - await client.del(...monthlyKeys); - stats.deletedMonthlyKeys += monthlyKeys.length; + await client.del(...monthlyKeys) + stats.deletedMonthlyKeys += monthlyKeys.length } // 重置API Key的lastUsedAt字段 - const keyData = await client.hgetall(`apikey:${keyId}`); + const keyData = await client.hgetall(`apikey:${keyId}`) if (keyData && Object.keys(keyData).length > 0) { - keyData.lastUsedAt = ''; - await client.hset(`apikey:${keyId}`, keyData); - stats.resetApiKeys++; + keyData.lastUsedAt = '' + await client.hset(`apikey:${keyId}`, keyData) + stats.resetApiKeys++ } } // 额外清理:删除所有可能遗漏的usage相关键 - const allUsageKeys = await client.keys('usage:*'); + const allUsageKeys = await client.keys('usage:*') if (allUsageKeys.length > 0) { - await client.del(...allUsageKeys); - stats.deletedKeys += allUsageKeys.length; + await client.del(...allUsageKeys) + stats.deletedKeys += allUsageKeys.length } - return stats; + return stats } catch (error) { - throw new Error(`Failed to reset usage stats: ${error.message}`); + throw new Error(`Failed to reset usage stats: ${error.message}`) } } // 🏢 Claude 账户管理 async setClaudeAccount(accountId, accountData) { - const key = `claude:account:${accountId}`; - await this.client.hset(key, accountData); + const key = `claude:account:${accountId}` + await this.client.hset(key, accountData) } async getClaudeAccount(accountId) { - const key = `claude:account:${accountId}`; - return await this.client.hgetall(key); + const key = `claude:account:${accountId}` + return await this.client.hgetall(key) } async getAllClaudeAccounts() { - const keys = await this.client.keys('claude:account:*'); - const accounts = []; + const keys = await this.client.keys('claude:account:*') + const accounts = [] for (const key of keys) { - const accountData = await this.client.hgetall(key); + const accountData = await this.client.hgetall(key) if (accountData && Object.keys(accountData).length > 0) { - accounts.push({ id: key.replace('claude:account:', ''), ...accountData }); + accounts.push({ id: key.replace('claude:account:', ''), ...accountData }) } } - return accounts; + return accounts } async deleteClaudeAccount(accountId) { - const key = `claude:account:${accountId}`; - return await this.client.del(key); + const key = `claude:account:${accountId}` + return await this.client.del(key) } // 🔐 会话管理(用于管理员登录等) async setSession(sessionId, sessionData, ttl = 86400) { - const key = `session:${sessionId}`; - await this.client.hset(key, sessionData); - await this.client.expire(key, ttl); + const key = `session:${sessionId}` + await this.client.hset(key, sessionData) + await this.client.expire(key, ttl) } async getSession(sessionId) { - const key = `session:${sessionId}`; - return await this.client.hgetall(key); + const key = `session:${sessionId}` + return await this.client.hgetall(key) } async deleteSession(sessionId) { - const key = `session:${sessionId}`; - return await this.client.del(key); + const key = `session:${sessionId}` + return await this.client.del(key) } // 🗝️ API Key哈希索引管理 async setApiKeyHash(hashedKey, keyData, ttl = 0) { - const key = `apikey_hash:${hashedKey}`; - await this.client.hset(key, keyData); + const key = `apikey_hash:${hashedKey}` + await this.client.hset(key, keyData) if (ttl > 0) { - await this.client.expire(key, ttl); + await this.client.expire(key, ttl) } } async getApiKeyHash(hashedKey) { - const key = `apikey_hash:${hashedKey}`; - return await this.client.hgetall(key); + const key = `apikey_hash:${hashedKey}` + return await this.client.hgetall(key) } async deleteApiKeyHash(hashedKey) { - const key = `apikey_hash:${hashedKey}`; - return await this.client.del(key); + const key = `apikey_hash:${hashedKey}` + return await this.client.del(key) } // 🔗 OAuth会话管理 - async setOAuthSession(sessionId, sessionData, ttl = 600) { // 10分钟过期 - const key = `oauth:${sessionId}`; + async setOAuthSession(sessionId, sessionData, ttl = 600) { + // 10分钟过期 + const key = `oauth:${sessionId}` // 序列化复杂对象,特别是 proxy 配置 - const serializedData = {}; + const serializedData = {} for (const [dataKey, value] of Object.entries(sessionData)) { if (typeof value === 'object' && value !== null) { - serializedData[dataKey] = JSON.stringify(value); + serializedData[dataKey] = JSON.stringify(value) } else { - serializedData[dataKey] = value; + serializedData[dataKey] = value } } - await this.client.hset(key, serializedData); - await this.client.expire(key, ttl); + await this.client.hset(key, serializedData) + await this.client.expire(key, ttl) } async getOAuthSession(sessionId) { - const key = `oauth:${sessionId}`; - const data = await this.client.hgetall(key); + const key = `oauth:${sessionId}` + const data = await this.client.hgetall(key) // 反序列化 proxy 字段 if (data.proxy) { try { - data.proxy = JSON.parse(data.proxy); + data.proxy = JSON.parse(data.proxy) } catch (error) { // 如果解析失败,设置为 null - data.proxy = null; + data.proxy = null } } - return data; + return data } async deleteOAuthSession(sessionId) { - const key = `oauth:${sessionId}`; - return await this.client.del(key); + const key = `oauth:${sessionId}` + return await this.client.del(key) } - // 📈 系统统计 async getSystemStats() { const keys = await Promise.all([ this.client.keys('apikey:*'), this.client.keys('claude:account:*'), this.client.keys('usage:*') - ]); + ]) return { totalApiKeys: keys[0].length, totalClaudeAccounts: keys[1].length, totalUsageRecords: keys[2].length - }; + } } // 📊 获取今日系统统计 async getTodayStats() { try { - const today = getDateStringInTimezone(); - const dailyKeys = await this.client.keys(`usage:daily:*:${today}`); + const today = getDateStringInTimezone() + const dailyKeys = await this.client.keys(`usage:daily:*:${today}`) - let totalRequestsToday = 0; - let totalTokensToday = 0; - let totalInputTokensToday = 0; - let totalOutputTokensToday = 0; - let totalCacheCreateTokensToday = 0; - let totalCacheReadTokensToday = 0; + let totalRequestsToday = 0 + let totalTokensToday = 0 + let totalInputTokensToday = 0 + let totalOutputTokensToday = 0 + let totalCacheCreateTokensToday = 0 + let totalCacheReadTokensToday = 0 // 批量获取所有今日数据,提高性能 if (dailyKeys.length > 0) { - const pipeline = this.client.pipeline(); - dailyKeys.forEach(key => pipeline.hgetall(key)); - const results = await pipeline.exec(); + const pipeline = this.client.pipeline() + dailyKeys.forEach((key) => pipeline.hgetall(key)) + const results = await pipeline.exec() for (const [error, dailyData] of results) { - if (error || !dailyData) continue; + if (error || !dailyData) { + continue + } - totalRequestsToday += parseInt(dailyData.requests) || 0; - const currentDayTokens = parseInt(dailyData.tokens) || 0; - totalTokensToday += currentDayTokens; + totalRequestsToday += parseInt(dailyData.requests) || 0 + const currentDayTokens = parseInt(dailyData.tokens) || 0 + totalTokensToday += currentDayTokens // 处理旧数据兼容性:如果有总token但没有输入输出分离,则使用总token作为输出token - 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 totalTokensFromSeparate = inputTokens + outputTokens; + 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 totalTokensFromSeparate = inputTokens + outputTokens if (totalTokensFromSeparate === 0 && currentDayTokens > 0) { // 旧数据:没有输入输出分离,假设70%为输出,30%为输入(基于一般对话比例) - totalOutputTokensToday += Math.round(currentDayTokens * 0.7); - totalInputTokensToday += Math.round(currentDayTokens * 0.3); + totalOutputTokensToday += Math.round(currentDayTokens * 0.7) + totalInputTokensToday += Math.round(currentDayTokens * 0.3) } else { // 新数据:使用实际的输入输出分离 - totalInputTokensToday += inputTokens; - totalOutputTokensToday += outputTokens; + totalInputTokensToday += inputTokens + totalOutputTokensToday += outputTokens } // 添加cache token统计 - totalCacheCreateTokensToday += cacheCreateTokens; - totalCacheReadTokensToday += cacheReadTokens; + totalCacheCreateTokensToday += cacheCreateTokens + totalCacheReadTokensToday += cacheReadTokens } } // 获取今日创建的API Key数量(批量优化) - const allApiKeys = await this.client.keys('apikey:*'); - let apiKeysCreatedToday = 0; + const allApiKeys = await this.client.keys('apikey:*') + let apiKeysCreatedToday = 0 if (allApiKeys.length > 0) { - const pipeline = this.client.pipeline(); - allApiKeys.forEach(key => pipeline.hget(key, 'createdAt')); - const results = await pipeline.exec(); + const pipeline = this.client.pipeline() + allApiKeys.forEach((key) => pipeline.hget(key, 'createdAt')) + const results = await pipeline.exec() for (const [error, createdAt] of results) { if (!error && createdAt && createdAt.startsWith(today)) { - apiKeysCreatedToday++; + apiKeysCreatedToday++ } } } @@ -951,9 +985,9 @@ class RedisClient { cacheCreateTokensToday: totalCacheCreateTokensToday, cacheReadTokensToday: totalCacheReadTokensToday, apiKeysCreatedToday - }; + } } catch (error) { - console.error('Error getting today stats:', error); + console.error('Error getting today stats:', error) return { requestsToday: 0, tokensToday: 0, @@ -962,52 +996,55 @@ class RedisClient { cacheCreateTokensToday: 0, cacheReadTokensToday: 0, apiKeysCreatedToday: 0 - }; + } } } // 📈 获取系统总的平均RPM和TPM async getSystemAverages() { try { - const allApiKeys = await this.client.keys('apikey:*'); - let totalRequests = 0; - let totalTokens = 0; - let totalInputTokens = 0; - let totalOutputTokens = 0; - let oldestCreatedAt = new Date(); + const allApiKeys = await this.client.keys('apikey:*') + let totalRequests = 0 + let totalTokens = 0 + let totalInputTokens = 0 + let totalOutputTokens = 0 + let oldestCreatedAt = new Date() // 批量获取所有usage数据和key数据,提高性能 - const usageKeys = allApiKeys.map(key => `usage:${key.replace('apikey:', '')}`); - const pipeline = this.client.pipeline(); + const usageKeys = allApiKeys.map((key) => `usage:${key.replace('apikey:', '')}`) + const pipeline = this.client.pipeline() // 添加所有usage查询 - usageKeys.forEach(key => pipeline.hgetall(key)); + usageKeys.forEach((key) => pipeline.hgetall(key)) // 添加所有key数据查询 - allApiKeys.forEach(key => pipeline.hgetall(key)); + allApiKeys.forEach((key) => pipeline.hgetall(key)) - const results = await pipeline.exec(); - const usageResults = results.slice(0, usageKeys.length); - const keyResults = results.slice(usageKeys.length); + const results = await pipeline.exec() + const usageResults = results.slice(0, usageKeys.length) + const keyResults = results.slice(usageKeys.length) for (let i = 0; i < allApiKeys.length; i++) { - const totalData = usageResults[i][1] || {}; - const keyData = keyResults[i][1] || {}; + const totalData = usageResults[i][1] || {} + const keyData = keyResults[i][1] || {} - totalRequests += parseInt(totalData.totalRequests) || 0; - totalTokens += parseInt(totalData.totalTokens) || 0; - totalInputTokens += parseInt(totalData.totalInputTokens) || 0; - totalOutputTokens += parseInt(totalData.totalOutputTokens) || 0; + totalRequests += parseInt(totalData.totalRequests) || 0 + totalTokens += parseInt(totalData.totalTokens) || 0 + totalInputTokens += parseInt(totalData.totalInputTokens) || 0 + totalOutputTokens += parseInt(totalData.totalOutputTokens) || 0 - const createdAt = keyData.createdAt ? new Date(keyData.createdAt) : new Date(); + const createdAt = keyData.createdAt ? new Date(keyData.createdAt) : new Date() if (createdAt < oldestCreatedAt) { - oldestCreatedAt = createdAt; + oldestCreatedAt = createdAt } } - const now = new Date(); + const now = new Date() // 保持与个人API Key计算一致的算法:按天计算然后转换为分钟 - const daysSinceOldest = Math.max(1, Math.ceil((now - oldestCreatedAt) / (1000 * 60 * 60 * 24))); - const totalMinutes = daysSinceOldest * 24 * 60; + const daysSinceOldest = Math.max( + 1, + Math.ceil((now - oldestCreatedAt) / (1000 * 60 * 60 * 24)) + ) + const totalMinutes = daysSinceOldest * 24 * 60 return { systemRPM: Math.round((totalRequests / totalMinutes) * 100) / 100, @@ -1015,76 +1052,82 @@ class RedisClient { totalInputTokens, totalOutputTokens, totalTokens - }; + } } catch (error) { - console.error('Error getting system averages:', error); + console.error('Error getting system averages:', error) return { systemRPM: 0, systemTPM: 0, totalInputTokens: 0, totalOutputTokens: 0, totalTokens: 0 - }; + } } } // 📊 获取实时系统指标(基于滑动窗口) async getRealtimeSystemMetrics() { try { - const config = require('../../config/config'); - const windowMinutes = config.system.metricsWindow || 5; - - const now = new Date(); - const currentMinute = Math.floor(now.getTime() / 60000); - + const configLocal = require('../../config/config') + const windowMinutes = configLocal.system.metricsWindow || 5 + + const now = new Date() + const currentMinute = Math.floor(now.getTime() / 60000) + // 调试:打印当前时间和分钟时间戳 - logger.debug(`🔍 Realtime metrics - Current time: ${now.toISOString()}, Minute timestamp: ${currentMinute}`); - + logger.debug( + `🔍 Realtime metrics - Current time: ${now.toISOString()}, Minute timestamp: ${currentMinute}` + ) + // 使用Pipeline批量获取窗口内的所有分钟数据 - const pipeline = this.client.pipeline(); - const minuteKeys = []; + const pipeline = this.client.pipeline() + const minuteKeys = [] for (let i = 0; i < windowMinutes; i++) { - const minuteKey = `system:metrics:minute:${currentMinute - i}`; - minuteKeys.push(minuteKey); - pipeline.hgetall(minuteKey); + const minuteKey = `system:metrics:minute:${currentMinute - i}` + minuteKeys.push(minuteKey) + pipeline.hgetall(minuteKey) } - - logger.debug(`🔍 Realtime metrics - Checking keys: ${minuteKeys.join(', ')}`); - - const results = await pipeline.exec(); - + + logger.debug(`🔍 Realtime metrics - Checking keys: ${minuteKeys.join(', ')}`) + + const results = await pipeline.exec() + // 聚合计算 - let totalRequests = 0; - let totalTokens = 0; - let totalInputTokens = 0; - let totalOutputTokens = 0; - let totalCacheCreateTokens = 0; - let totalCacheReadTokens = 0; - let validDataCount = 0; - + let totalRequests = 0 + let totalTokens = 0 + let totalInputTokens = 0 + let totalOutputTokens = 0 + let totalCacheCreateTokens = 0 + let totalCacheReadTokens = 0 + let validDataCount = 0 + results.forEach(([err, data], index) => { if (!err && data && Object.keys(data).length > 0) { - validDataCount++; - totalRequests += parseInt(data.requests || 0); - totalTokens += parseInt(data.totalTokens || 0); - totalInputTokens += parseInt(data.inputTokens || 0); - totalOutputTokens += parseInt(data.outputTokens || 0); - totalCacheCreateTokens += parseInt(data.cacheCreateTokens || 0); - totalCacheReadTokens += parseInt(data.cacheReadTokens || 0); - + validDataCount++ + totalRequests += parseInt(data.requests || 0) + totalTokens += parseInt(data.totalTokens || 0) + totalInputTokens += parseInt(data.inputTokens || 0) + totalOutputTokens += parseInt(data.outputTokens || 0) + totalCacheCreateTokens += parseInt(data.cacheCreateTokens || 0) + totalCacheReadTokens += parseInt(data.cacheReadTokens || 0) + logger.debug(`🔍 Realtime metrics - Key ${minuteKeys[index]} data:`, { requests: data.requests, totalTokens: data.totalTokens - }); + }) } - }); - - logger.debug(`🔍 Realtime metrics - Valid data count: ${validDataCount}/${windowMinutes}, Total requests: ${totalRequests}, Total tokens: ${totalTokens}`); - + }) + + logger.debug( + `🔍 Realtime metrics - Valid data count: ${validDataCount}/${windowMinutes}, Total requests: ${totalRequests}, Total tokens: ${totalTokens}` + ) + // 计算平均值(每分钟) - const realtimeRPM = windowMinutes > 0 ? Math.round((totalRequests / windowMinutes) * 100) / 100 : 0; - const realtimeTPM = windowMinutes > 0 ? Math.round((totalTokens / windowMinutes) * 100) / 100 : 0; - + const realtimeRPM = + windowMinutes > 0 ? Math.round((totalRequests / windowMinutes) * 100) / 100 : 0 + const realtimeTPM = + windowMinutes > 0 ? Math.round((totalTokens / windowMinutes) * 100) / 100 : 0 + const result = { realtimeRPM, realtimeTPM, @@ -1095,15 +1138,15 @@ class RedisClient { totalOutputTokens, totalCacheCreateTokens, totalCacheReadTokens - }; - - logger.debug('🔍 Realtime metrics - Final result:', result); - - return result; + } + + logger.debug('🔍 Realtime metrics - Final result:', result) + + return result } catch (error) { - console.error('Error getting realtime system metrics:', error); + console.error('Error getting realtime system metrics:', error) // 如果出错,返回历史平均值作为降级方案 - const historicalMetrics = await this.getSystemAverages(); + const historicalMetrics = await this.getSystemAverages() return { realtimeRPM: historicalMetrics.systemRPM, realtimeTPM: historicalMetrics.systemTPM, @@ -1114,85 +1157,80 @@ class RedisClient { totalOutputTokens: historicalMetrics.totalOutputTokens, totalCacheCreateTokens: 0, totalCacheReadTokens: 0 - }; + } } } // 🔗 会话sticky映射管理 async setSessionAccountMapping(sessionHash, accountId, ttl = 3600) { - const key = `sticky_session:${sessionHash}`; - await this.client.set(key, accountId, 'EX', ttl); + const key = `sticky_session:${sessionHash}` + await this.client.set(key, accountId, 'EX', ttl) } async getSessionAccountMapping(sessionHash) { - const key = `sticky_session:${sessionHash}`; - return await this.client.get(key); + const key = `sticky_session:${sessionHash}` + return await this.client.get(key) } async deleteSessionAccountMapping(sessionHash) { - const key = `sticky_session:${sessionHash}`; - return await this.client.del(key); + const key = `sticky_session:${sessionHash}` + return await this.client.del(key) } // 🧹 清理过期数据 async cleanup() { try { - const patterns = [ - 'usage:daily:*', - 'ratelimit:*', - 'session:*', - 'sticky_session:*', - 'oauth:*' - ]; + const patterns = ['usage:daily:*', 'ratelimit:*', 'session:*', 'sticky_session:*', 'oauth:*'] for (const pattern of patterns) { - const keys = await this.client.keys(pattern); - const pipeline = this.client.pipeline(); + const keys = await this.client.keys(pattern) + const pipeline = this.client.pipeline() for (const key of keys) { - const ttl = await this.client.ttl(key); - if (ttl === -1) { // 没有设置过期时间的键 + const ttl = await this.client.ttl(key) + if (ttl === -1) { + // 没有设置过期时间的键 if (key.startsWith('oauth:')) { - pipeline.expire(key, 600); // OAuth会话设置10分钟过期 + pipeline.expire(key, 600) // OAuth会话设置10分钟过期 } else { - pipeline.expire(key, 86400); // 其他设置1天过期 + pipeline.expire(key, 86400) // 其他设置1天过期 } } } - await pipeline.exec(); + await pipeline.exec() } - logger.info('🧹 Redis cleanup completed'); + logger.info('🧹 Redis cleanup completed') } catch (error) { - logger.error('❌ Redis cleanup failed:', error); + logger.error('❌ Redis cleanup failed:', error) } } // 增加并发计数 async incrConcurrency(apiKeyId) { try { - const key = `concurrency:${apiKeyId}`; - const count = await this.client.incr(key); - + const key = `concurrency:${apiKeyId}` + const count = await this.client.incr(key) + // 设置过期时间为180秒(3分钟),防止计数器永远不清零 // 正常情况下请求会在完成时主动减少计数,这只是一个安全保障 // 180秒足够支持较长的流式请求 - await this.client.expire(key, 180); - - logger.database(`🔢 Incremented concurrency for key ${apiKeyId}: ${count}`); - return count; + await this.client.expire(key, 180) + + logger.database(`🔢 Incremented concurrency for key ${apiKeyId}: ${count}`) + return count } catch (error) { - logger.error('❌ Failed to increment concurrency:', error); - throw error; + logger.error('❌ Failed to increment concurrency:', error) + throw error } } // 减少并发计数 async decrConcurrency(apiKeyId) { try { - const key = `concurrency:${apiKeyId}`; - + const key = `concurrency:${apiKeyId}` + // 使用Lua脚本确保原子性操作,防止计数器变成负数 const luaScript = ` local key = KEYS[1] @@ -1210,35 +1248,35 @@ class RedisClient { return new_value end end - `; - - const count = await this.client.eval(luaScript, 1, key); - logger.database(`🔢 Decremented concurrency for key ${apiKeyId}: ${count}`); - return count; + ` + + const count = await this.client.eval(luaScript, 1, key) + logger.database(`🔢 Decremented concurrency for key ${apiKeyId}: ${count}`) + return count } catch (error) { - logger.error('❌ Failed to decrement concurrency:', error); - throw error; + logger.error('❌ Failed to decrement concurrency:', error) + throw error } } // 获取当前并发数 async getConcurrency(apiKeyId) { try { - const key = `concurrency:${apiKeyId}`; - const count = await this.client.get(key); - return parseInt(count || 0); + const key = `concurrency:${apiKeyId}` + const count = await this.client.get(key) + return parseInt(count || 0) } catch (error) { - logger.error('❌ Failed to get concurrency:', error); - return 0; + logger.error('❌ Failed to get concurrency:', error) + return 0 } } } -const redisClient = new RedisClient(); +const redisClient = new RedisClient() // 导出时区辅助函数 -redisClient.getDateInTimezone = getDateInTimezone; -redisClient.getDateStringInTimezone = getDateStringInTimezone; -redisClient.getHourInTimezone = getHourInTimezone; +redisClient.getDateInTimezone = getDateInTimezone +redisClient.getDateStringInTimezone = getDateStringInTimezone +redisClient.getHourInTimezone = getHourInTimezone -module.exports = redisClient; \ No newline at end of file +module.exports = redisClient diff --git a/src/routes/admin.js b/src/routes/admin.js index 01c75810..7a77c301 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1,160 +1,166 @@ -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 geminiAccountService = require('../services/geminiAccountService'); -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 CostCalculator = require('../utils/costCalculator'); -const pricingService = require('../services/pricingService'); -const claudeCodeHeadersService = require('../services/claudeCodeHeadersService'); -const axios = require('axios'); -const fs = require('fs'); -const path = require('path'); -const config = require('../../config/config'); +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 geminiAccountService = require('../services/geminiAccountService') +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 CostCalculator = require('../utils/costCalculator') +const pricingService = require('../services/pricingService') +const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') +const axios = require('axios') +const fs = require('fs') +const path = require('path') +const config = require('../../config/config') -const router = express.Router(); +const router = express.Router() // 🔑 API Keys 管理 // 调试:获取API Key费用详情 router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => { try { - const { keyId } = req.params; - const costStats = await redis.getCostStats(keyId); - const dailyCost = await redis.getDailyCost(keyId); - const today = redis.getDateStringInTimezone(); - const client = redis.getClientSafe(); - + 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 = {}; - + const costKeys = await client.keys(`usage:cost:*:${keyId}:*`) + const keyValues = {} + for (const key of costKeys) { - keyValues[key] = await client.get(key); + keyValues[key] = await client.get(key) } - - res.json({ + + 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); - res.status(500).json({ error: 'Failed to get cost debug info', message: error.message }); + 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' } = req.query; // all, 7days, monthly - const apiKeys = await apiKeyService.getAllApiKeys(); - + const { timeRange = 'all' } = req.query // all, 7days, monthly + const apiKeys = await apiKeyService.getAllApiKeys() + // 根据时间范围计算查询模式 - const now = new Date(); - let searchPatterns = []; - + const now = new Date() + const searchPatterns = [] + if (timeRange === 'today') { // 今日 - 使用时区日期 - const redis = require('../models/redis'); - const tzDate = redis.getDateInTimezone(now); - const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}`; - searchPatterns.push(`usage:daily:*:${dateStr}`); + 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 redis = require('../models/redis'); + const redisClient = require('../models/redis') for (let i = 0; i < 7; i++) { - const date = new Date(now); - date.setDate(date.getDate() - i); - const tzDate = redis.getDateInTimezone(date); - const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}`; - searchPatterns.push(`usage:daily:*:${dateStr}`); + 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 redis = require('../models/redis'); - const tzDate = redis.getDateInTimezone(now); - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`; - searchPatterns.push(`usage:monthly:*:${currentMonth}`); + 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(); - + 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 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 } - - 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; - } - + + 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); + apiKey.usage.total.cost = totalCost + apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost) } } else { // 7天或本月:重新计算统计数据 @@ -166,62 +172,73 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0 - }; - + } + // 获取指定时间范围的统计数据 for (const pattern of searchPatterns) { - const keys = await client.keys(pattern.replace('*', apiKey.id)); - + const keys = await client.keys(pattern.replace('*', apiKey.id)) + for (const key of keys) { - const data = await client.hgetall(key); + 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; + tempUsage.requests += parseInt(data.totalRequests) || parseInt(data.requests) || 0 + tempUsage.tokens += parseInt(data.totalTokens) || parseInt(data.tokens) || 0 + tempUsage.allTokens += parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0 // 读取包含所有Token的字段 + tempUsage.inputTokens += + parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0 + tempUsage.outputTokens += + parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0 + tempUsage.cacheCreateTokens += + parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0 + tempUsage.cacheReadTokens += + parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 } } } - + // 计算指定时间范围的费用 - let totalCost = 0; - const redis = require('../models/redis'); - const tzToday = redis.getDateStringInTimezone(now); - const tzDate = redis.getDateInTimezone(now); - const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`; - - const modelKeys = timeRange === 'today' - ? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`) - : timeRange === '7days' - ? await client.keys(`usage:${apiKey.id}:model:daily:*:*`) - : await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`); - - const modelStatsMap = new Map(); - + 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')}` + + const modelKeys = + timeRange === 'today' + ? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`) + : timeRange === '7days' + ? await client.keys(`usage:${apiKey.id}:model:daily:*:*`) + : await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`) + + const modelStatsMap = new Map() + // 过滤和汇总相应时间范围的模型数据 for (const key of modelKeys) { if (timeRange === '7days') { // 检查是否在最近7天内 - const dateMatch = key.match(/\d{4}-\d{2}-\d{2}$/); + 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; + const keyDate = new Date(dateMatch[0]) + const daysDiff = Math.floor((now - keyDate) / (1000 * 60 * 60 * 24)) + if (daysDiff > 6) { + continue + } } } else if (timeRange === 'today') { // today选项已经在查询时过滤了,不需要额外处理 } - - const modelMatch = key.match(/usage:.+:model:(?:daily|monthly):(.+):\d{4}-\d{2}(?:-\d{2})?$/); - if (!modelMatch) continue; - - const model = modelMatch[1]; - const data = await client.hgetall(key); - + + 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, { @@ -229,17 +246,20 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { 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; + + 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 = { @@ -247,12 +267,12 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { 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; + } + + const costResult = CostCalculator.calculateCost(usage, model) + totalCost += costResult.costs.total } - + // 如果没有模型数据,使用临时统计数据计算 if (modelStatsMap.size === 0 && tempUsage.tokens > 0) { const usage = { @@ -260,35 +280,40 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { 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; + } + + 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); - + const allTokens = + tempUsage.allTokens || + tempUsage.inputTokens + + tempUsage.outputTokens + + tempUsage.cacheCreateTokens + + tempUsage.cacheReadTokens + // 更新API Key的usage数据为指定时间范围的数据 apiKey.usage[timeRange] = { ...tempUsage, tokens: allTokens, // 使用包含所有Token的总数 - allTokens: allTokens, + allTokens, cost: totalCost, formattedCost: CostCalculator.formatCost(totalCost) - }; - + } + // 为了保持兼容性,也更新total字段 - apiKey.usage.total = apiKey.usage[timeRange]; + apiKey.usage.total = apiKey.usage[timeRange] } } - - res.json({ success: true, data: apiKeys }); + + return res.json({ success: true, data: apiKeys }) } catch (error) { - logger.error('❌ Failed to get API keys:', error); - res.status(500).json({ error: 'Failed to get API keys', message: error.message }); + 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) => { @@ -305,48 +330,50 @@ router.get('/supported-clients', authenticateAdmin, async (req, res) => { name: 'Gemini-CLI', description: 'Gemini Command Line Interface' } - ]; - - const clients = predefinedClients.map(client => ({ + ] + + const clients = predefinedClients.map((client) => ({ id: client.id, name: client.name, description: client.description - })); - - res.json({ success: true, data: clients }); + })) + + return res.json({ success: true, data: clients }) } catch (error) { - logger.error('❌ Failed to get supported clients:', error); - res.status(500).json({ error: 'Failed to get supported clients', message: error.message }); + 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(); - + 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 => { + apiKey.tags.forEach((tag) => { if (tag && tag.trim()) { - tagSet.add(tag.trim()); + tagSet.add(tag.trim()) } - }); + }) } } - + // 转换为数组并排序 - const tags = Array.from(tagSet).sort(); - - logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`); - res.json({ success: true, data: tags }); + 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); - res.status(500).json({ error: 'Failed to get API key tags', message: error.message }); + 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) => { @@ -369,63 +396,81 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { allowedClients, dailyCostLimit, tags - } = req.body; + } = 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' }); + 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' }); + 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' }); + 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' }); + 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 (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 (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 ( + 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' }); + 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' }); + 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' }); + 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' }); + 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' }); + 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 (tags && tags.some((tag) => typeof tag !== 'string' || tag.trim().length === 0)) { + return res.status(400).json({ error: 'All tags must be non-empty strings' }) } const newKey = await apiKeyService.generateApiKey({ @@ -446,15 +491,15 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { allowedClients, dailyCostLimit, tags - }); + }) - logger.success(`🔑 Admin created new API key: ${name}`); - res.json({ success: true, data: newKey }); + 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); - res.status(500).json({ error: 'Failed to create API key', message: error.message }); + 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) => { @@ -478,28 +523,30 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { allowedClients, dailyCostLimit, tags - } = req.body; + } = 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' }); + 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' }); + 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' }); + return res + .status(400) + .json({ error: 'Base name must be less than 90 characters to allow for numbering' }) } // 生成批量API Keys - const createdKeys = []; - const errors = []; + const createdKeys = [] + const errors = [] for (let i = 1; i <= count; i++) { try { - const name = `${baseName}_${i}`; + const name = `${baseName}_${i}` const newKey = await apiKeyService.generateApiKey({ name, description, @@ -518,33 +565,33 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { allowedClients, dailyCostLimit, tags - }); - + }) + // 保留原始 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({ + return res.status(400).json({ success: false, - error: 'Failed to create any API keys', - errors - }); + error: 'Failed to create any API keys', + errors + }) } // 返回创建的keys(包含完整的apiKey) - res.json({ + return res.json({ success: true, data: createdKeys, errors: errors.length > 0 ? errors : undefined, @@ -553,289 +600,309 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { created: createdKeys.length, failed: errors.length } - }); + }) } catch (error) { - logger.error('Failed to batch create API keys:', error); - res.status(500).json({ + 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 - }); + error: 'Failed to batch create API keys', + message: error.message + }) } -}); +}) // 更新API Key router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { - const { keyId } = req.params; - const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, claudeConsoleAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt, dailyCostLimit, tags } = req.body; + const { keyId } = req.params + const { + tokenLimit, + concurrencyLimit, + rateLimitWindow, + rateLimitRequests, + claudeAccountId, + claudeConsoleAccountId, + geminiAccountId, + permissions, + enableModelRestriction, + restrictedModels, + enableClientRestriction, + allowedClients, + expiresAt, + dailyCostLimit, + tags + } = req.body // 只允许更新指定字段 - const updates = {}; - + const updates = {} + if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') { if (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0) { - return res.status(400).json({ error: 'Token limit must be a non-negative integer' }); + return res.status(400).json({ error: 'Token limit must be a non-negative integer' }) } - updates.tokenLimit = Number(tokenLimit); + 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' }); + return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' }) } - updates.concurrencyLimit = Number(concurrencyLimit); + 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)' }); + return res + .status(400) + .json({ error: 'Rate limit window must be a non-negative integer (minutes)' }) } - updates.rateLimitWindow = Number(rateLimitWindow); + 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' }); + return res.status(400).json({ error: 'Rate limit requests must be a non-negative integer' }) } - updates.rateLimitRequests = Number(rateLimitRequests); + updates.rateLimitRequests = Number(rateLimitRequests) } if (claudeAccountId !== undefined) { // 空字符串表示解绑,null或空字符串都设置为空字符串 - updates.claudeAccountId = claudeAccountId || ''; + updates.claudeAccountId = claudeAccountId || '' } - + if (claudeConsoleAccountId !== undefined) { // 空字符串表示解绑,null或空字符串都设置为空字符串 - updates.claudeConsoleAccountId = claudeConsoleAccountId || ''; + updates.claudeConsoleAccountId = claudeConsoleAccountId || '' } if (geminiAccountId !== undefined) { // 空字符串表示解绑,null或空字符串都设置为空字符串 - updates.geminiAccountId = geminiAccountId || ''; + updates.geminiAccountId = geminiAccountId || '' } if (permissions !== undefined) { // 验证权限值 if (!['claude', 'gemini', 'all'].includes(permissions)) { - return res.status(400).json({ error: 'Invalid permissions value. Must be claude, gemini, or all' }); + return res + .status(400) + .json({ error: 'Invalid permissions value. Must be claude, gemini, or all' }) } - updates.permissions = permissions; + updates.permissions = permissions } // 处理模型限制字段 if (enableModelRestriction !== undefined) { if (typeof enableModelRestriction !== 'boolean') { - return res.status(400).json({ error: 'Enable model restriction must be a boolean' }); + return res.status(400).json({ error: 'Enable model restriction must be a boolean' }) } - updates.enableModelRestriction = enableModelRestriction; + updates.enableModelRestriction = enableModelRestriction } if (restrictedModels !== undefined) { if (!Array.isArray(restrictedModels)) { - return res.status(400).json({ error: 'Restricted models must be an array' }); + return res.status(400).json({ error: 'Restricted models must be an array' }) } - updates.restrictedModels = restrictedModels; + updates.restrictedModels = restrictedModels } // 处理客户端限制字段 if (enableClientRestriction !== undefined) { if (typeof enableClientRestriction !== 'boolean') { - return res.status(400).json({ error: 'Enable client restriction must be a boolean' }); + return res.status(400).json({ error: 'Enable client restriction must be a boolean' }) } - updates.enableClientRestriction = enableClientRestriction; + updates.enableClientRestriction = enableClientRestriction } if (allowedClients !== undefined) { if (!Array.isArray(allowedClients)) { - return res.status(400).json({ error: 'Allowed clients must be an array' }); + return res.status(400).json({ error: 'Allowed clients must be an array' }) } - updates.allowedClients = allowedClients; + updates.allowedClients = allowedClients } // 处理过期时间字段 if (expiresAt !== undefined) { if (expiresAt === null) { // null 表示永不过期 - updates.expiresAt = null; + updates.expiresAt = null } else { // 验证日期格式 - const expireDate = new Date(expiresAt); + const expireDate = new Date(expiresAt) if (isNaN(expireDate.getTime())) { - return res.status(400).json({ error: 'Invalid expiration date format' }); + return res.status(400).json({ error: 'Invalid expiration date format' }) } - updates.expiresAt = expiresAt; + updates.expiresAt = expiresAt } } // 处理每日费用限制 if (dailyCostLimit !== undefined && dailyCostLimit !== null && dailyCostLimit !== '') { - const costLimit = Number(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' }); + return res.status(400).json({ error: 'Daily cost limit must be a non-negative number' }) } - updates.dailyCostLimit = costLimit; + updates.dailyCostLimit = costLimit } // 处理标签 if (tags !== undefined) { if (!Array.isArray(tags)) { - return res.status(400).json({ error: 'Tags must be an array' }); + 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' }); + 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; + updates.tags = tags } - await apiKeyService.updateApiKey(keyId, updates); - - logger.success(`📝 Admin updated API key: ${keyId}`); - res.json({ success: true, message: 'API key updated successfully' }); + 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); - res.status(500).json({ error: 'Failed to update API key', message: error.message }); + 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.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { - const { keyId } = req.params; - - await apiKeyService.deleteApiKey(keyId); - - logger.success(`🗑️ Admin deleted API key: ${keyId}`); - res.json({ success: true, message: 'API key deleted successfully' }); + const { keyId } = req.params + + await apiKeyService.deleteApiKey(keyId) + + 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); - res.status(500).json({ error: 'Failed to delete API key', message: error.message }); + logger.error('❌ Failed to delete API key:', error) + return res.status(500).json({ error: 'Failed to delete API key', message: error.message }) } -}); +}) // 👥 账户分组管理 // 创建账户分组 router.post('/account-groups', authenticateAdmin, async (req, res) => { try { - const { name, platform, description } = req.body; - + const { name, platform, description } = req.body + const group = await accountGroupService.createGroup({ name, platform, description - }); - - res.json({ success: true, data: group }); + }) + + return res.json({ success: true, data: group }) } catch (error) { - logger.error('❌ Failed to create account group:', error); - res.status(400).json({ error: error.message }); + 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); - res.json({ success: true, data: groups }); + 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); - res.status(500).json({ error: error.message }); + 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); - + const { groupId } = req.params + const group = await accountGroupService.getGroup(groupId) + if (!group) { - return res.status(404).json({ error: '分组不存在' }); + return res.status(404).json({ error: '分组不存在' }) } - - res.json({ success: true, data: group }); + + return res.json({ success: true, data: group }) } catch (error) { - logger.error('❌ Failed to get account group:', error); - res.status(500).json({ error: error.message }); + 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); - res.json({ success: true, data: updatedGroup }); + 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); - res.status(400).json({ error: error.message }); + 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); - res.json({ success: true, message: '分组删除成功' }); + const { groupId } = req.params + await accountGroupService.deleteGroup(groupId) + return res.json({ success: true, message: '分组删除成功' }) } catch (error) { - logger.error('❌ Failed to delete account group:', error); - res.status(400).json({ error: error.message }); + 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 memberIds = await accountGroupService.getGroupMembers(groupId); - + const { groupId } = req.params + const memberIds = await accountGroupService.getGroupMembers(groupId) + // 获取成员详细信息 - const members = []; + const members = [] for (const memberId of memberIds) { // 尝试从不同的服务获取账户信息 - let account = null; - + let account = null + // 先尝试Claude OAuth账户 - account = await claudeAccountService.getAccount(memberId); - + account = await claudeAccountService.getAccount(memberId) + // 如果找不到,尝试Claude Console账户 if (!account) { - account = await claudeConsoleAccountService.getAccount(memberId); + account = await claudeConsoleAccountService.getAccount(memberId) } - + // 如果还找不到,尝试Gemini账户 if (!account) { - account = await geminiAccountService.getAccount(memberId); + account = await geminiAccountService.getAccount(memberId) } - + if (account) { - members.push(account); + members.push(account) } } - - res.json({ success: true, data: members }); + + return res.json({ success: true, data: members }) } catch (error) { - logger.error('❌ Failed to get group members:', error); - res.status(500).json({ error: error.message }); + 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(); - + const { proxy } = req.body // 接收代理配置 + const oauthParams = await oauthHelper.generateOAuthParams() + // 将codeVerifier和state临时存储到Redis,用于后续验证 - const sessionId = require('crypto').randomUUID(); + const sessionId = require('crypto').randomUUID() await redis.setOAuthSession(sessionId, { codeVerifier: oauthParams.codeVerifier, state: oauthParams.state, @@ -843,14 +910,14 @@ router.post('/claude-accounts/generate-auth-url', authenticateAdmin, async (req, proxy: proxy || null, // 存储代理配置 createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期 - }); - - logger.success('🔗 Generated OAuth authorization URL with proxy support'); - res.json({ - success: true, + }) + + logger.success('🔗 Generated OAuth authorization URL with proxy support') + return res.json({ + success: true, data: { authUrl: oauthParams.authUrl, - sessionId: sessionId, + sessionId, instructions: [ '1. 复制上面的链接到浏览器中打开', '2. 登录您的 Anthropic 账户', @@ -859,111 +926,129 @@ router.post('/claude-accounts/generate-auth-url', authenticateAdmin, async (req, '5. 在添加账户表单中粘贴完整的回调 URL 和授权码' ] } - }); + }) } catch (error) { - logger.error('❌ Failed to generate OAuth URL:', error); - res.status(500).json({ error: 'Failed to generate OAuth URL', message: error.message }); + 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; - + 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' }); + return res + .status(400) + .json({ error: 'Session ID and authorization code (or callback URL) are required' }) } - + // 从Redis获取OAuth会话信息 - const oauthSession = await redis.getOAuthSession(sessionId); + const oauthSession = await redis.getOAuthSession(sessionId) if (!oauthSession) { - return res.status(400).json({ error: 'Invalid or expired OAuth session' }); + 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' }); + 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; - + let finalAuthCode + const inputValue = callbackUrl || authorizationCode + try { - finalAuthCode = oauthHelper.parseCallbackUrl(inputValue); + finalAuthCode = oauthHelper.parseCallbackUrl(inputValue) } catch (parseError) { - return res.status(400).json({ error: 'Failed to parse authorization input', message: parseError.message }); + return res + .status(400) + .json({ error: 'Failed to parse authorization input', message: parseError.message }) } - + // 交换访问令牌 const tokenData = await oauthHelper.exchangeCodeForTokens( finalAuthCode, oauthSession.codeVerifier, oauthSession.state, oauthSession.proxy // 传递代理配置 - ); - + ) + // 清理OAuth会话 - await redis.deleteOAuthSession(sessionId); - - logger.success('🎉 Successfully exchanged authorization code for tokens'); - res.json({ - success: true, + 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') - }); - res.status(500).json({ error: 'Failed to exchange authorization code', message: error.message }); + 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账户 router.get('/claude-accounts', authenticateAdmin, async (req, res) => { try { - const accounts = await claudeAccountService.getAllAccounts(); - + const accounts = await claudeAccountService.getAllAccounts() + // 为每个账户添加使用统计信息 - const accountsWithStats = await Promise.all(accounts.map(async (account) => { - try { - const usageStats = await redis.getAccountUsageStats(account.id); - return { - ...account, - usage: { - daily: usageStats.daily, - total: usageStats.total, - averages: usageStats.averages + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id) + return { + ...account, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } } - }; - } catch (statsError) { - logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message); - // 如果获取统计失败,返回空统计 - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + } catch (statsError) { + logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message) + // 如果获取统计失败,返回空统计 + return { + ...account, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } } - }; - } - })); - - res.json({ success: true, data: accountsWithStats }); + } + }) + ) + + return res.json({ success: true, data: accountsWithStats }) } catch (error) { - logger.error('❌ Failed to get Claude accounts:', error); - res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message }); + logger.error('❌ Failed to get Claude accounts:', error) + return res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message }) } -}); +}) // 创建新的Claude账户 router.post('/claude-accounts', authenticateAdmin, async (req, res) => { @@ -979,25 +1064,30 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { accountType, priority, groupId - } = req.body; + } = req.body if (!name) { - return res.status(400).json({ error: 'Name is required' }); + 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"' }); + 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' }); + return res.status(400).json({ error: 'Group ID is 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' }); + 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({ @@ -1010,175 +1100,201 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { proxy, accountType: accountType || 'shared', // 默认为共享类型 priority: priority || 50 // 默认优先级为50 - }); + }) // 如果是分组类型,将账户添加到分组 if (accountType === 'group' && groupId) { - await accountGroupService.addAccountToGroup(newAccount.id, groupId, newAccount.platform); + await accountGroupService.addAccountToGroup(newAccount.id, groupId, newAccount.platform) } - logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`); - res.json({ success: true, data: newAccount }); + logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`) + return res.json({ success: true, data: newAccount }) } catch (error) { - logger.error('❌ Failed to create Claude account:', error); - res.status(500).json({ error: 'Failed to create Claude account', message: error.message }); + 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; + const { accountId } = req.params + const updates = req.body // 验证priority的有效性 - if (updates.priority !== undefined && (typeof updates.priority !== 'number' || updates.priority < 1 || updates.priority > 100)) { - return res.status(400).json({ error: 'Priority must be a number between 1 and 100' }); + if ( + updates.priority !== undefined && + (typeof updates.priority !== 'number' || updates.priority < 1 || updates.priority > 100) + ) { + return res.status(400).json({ error: 'Priority must be a number between 1 and 100' }) } // 验证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"' }); + 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' }); + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) } // 获取账户当前信息以处理分组变更 - const currentAccount = await claudeAccountService.getAccount(accountId); + const currentAccount = await claudeAccountService.getAccount(accountId) if (!currentAccount) { - return res.status(404).json({ error: 'Account not found' }); + return res.status(404).json({ error: 'Account not found' }) } // 处理分组的变更 if (updates.accountType !== undefined) { // 如果之前是分组类型,需要从原分组中移除 if (currentAccount.accountType === 'group') { - const oldGroup = await accountGroupService.getAccountGroup(accountId); + const oldGroup = await accountGroupService.getAccountGroup(accountId) if (oldGroup) { - await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id); + await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) } } // 如果新类型是分组,添加到新分组 if (updates.accountType === 'group' && updates.groupId) { // 从路由知道这是 Claude OAuth 账户,平台为 'claude' - await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude'); + await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude') } } - await claudeAccountService.updateAccount(accountId, updates); - - logger.success(`📝 Admin updated Claude account: ${accountId}`); - res.json({ success: true, message: 'Claude account updated successfully' }); + await claudeAccountService.updateAccount(accountId, updates) + + 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); - res.status(500).json({ error: 'Failed to update Claude account', message: error.message }); + 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; - + const { accountId } = req.params + // 获取账户信息以检查是否在分组中 - const account = await claudeAccountService.getAccount(accountId); + const account = await claudeAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const group = await accountGroupService.getAccountGroup(accountId); + const group = await accountGroupService.getAccountGroup(accountId) if (group) { - await accountGroupService.removeAccountFromGroup(accountId, group.id); + await accountGroupService.removeAccountFromGroup(accountId, group.id) } } - - await claudeAccountService.deleteAccount(accountId); - - logger.success(`🗑️ Admin deleted Claude account: ${accountId}`); - res.json({ success: true, message: 'Claude account deleted successfully' }); + + await claudeAccountService.deleteAccount(accountId) + + logger.success(`🗑️ Admin deleted Claude account: ${accountId}`) + return res.json({ success: true, message: 'Claude account deleted successfully' }) } catch (error) { - logger.error('❌ Failed to delete Claude account:', error); - res.status(500).json({ error: 'Failed to delete Claude account', message: error.message }); + logger.error('❌ Failed to delete Claude account:', error) + return res + .status(500) + .json({ error: 'Failed to delete Claude account', message: error.message }) } -}); +}) // 刷新Claude账户token router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => { try { - const { accountId } = req.params; - - const result = await claudeAccountService.refreshAccountToken(accountId); - - logger.success(`🔄 Admin refreshed token for Claude account: ${accountId}`); - res.json({ success: true, data: result }); + 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); - res.status(500).json({ error: 'Failed to refresh token', message: error.message }); + logger.error('❌ Failed to refresh Claude account token:', error) + return res.status(500).json({ error: 'Failed to refresh token', 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' }); +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 }) + + 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 }) } - - const newSchedulable = !account.schedulable; - await claudeAccountService.updateAccount(accountId, { schedulable: newSchedulable }); - - logger.success(`🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`); - res.json({ success: true, schedulable: newSchedulable }); - } catch (error) { - logger.error('❌ Failed to toggle Claude account schedulable status:', error); - 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 accounts = await claudeConsoleAccountService.getAllAccounts(); - + const accounts = await claudeConsoleAccountService.getAllAccounts() + // 为每个账户添加使用统计信息 - const accountsWithStats = await Promise.all(accounts.map(async (account) => { - try { - const usageStats = await redis.getAccountUsageStats(account.id); - return { - ...account, - usage: { - daily: usageStats.daily, - total: usageStats.total, - averages: usageStats.averages + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id) + return { + ...account, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } } - }; - } catch (statsError) { - logger.warn(`⚠️ Failed to get usage stats for Claude Console account ${account.id}:`, statsError.message); - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + } catch (statsError) { + logger.warn( + `⚠️ Failed to get usage stats for Claude Console account ${account.id}:`, + statsError.message + ) + return { + ...account, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } } - }; - } - })); - - res.json({ success: true, data: accountsWithStats }); + } + }) + ) + + return res.json({ success: true, data: accountsWithStats }) } catch (error) { - logger.error('❌ Failed to get Claude Console accounts:', error); - res.status(500).json({ error: 'Failed to get Claude Console accounts', message: error.message }); + 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) => { @@ -1195,25 +1311,27 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => { proxy, accountType, groupId - } = req.body; + } = req.body if (!name || !apiUrl || !apiKey) { - return res.status(400).json({ error: 'Name, API URL and API Key are required' }); + 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' }); + 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"' }); + 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' }); + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) } const newAccount = await claudeConsoleAccountService.createAccount({ @@ -1227,182 +1345,208 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => { rateLimitDuration: rateLimitDuration || 60, proxy, accountType: accountType || 'shared' - }); + }) // 如果是分组类型,将账户添加到分组 if (accountType === 'group' && groupId) { - await accountGroupService.addAccountToGroup(newAccount.id, groupId, 'claude'); + await accountGroupService.addAccountToGroup(newAccount.id, groupId, 'claude') } - logger.success(`🎮 Admin created Claude Console account: ${name}`); - res.json({ success: true, data: newAccount }); + logger.success(`🎮 Admin created Claude Console account: ${name}`) + return res.json({ success: true, data: newAccount }) } catch (error) { - logger.error('❌ Failed to create Claude Console account:', error); - res.status(500).json({ error: 'Failed to create Claude Console account', message: error.message }); + 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; + const { accountId } = req.params + const updates = req.body // 验证priority的有效性(1-100) if (updates.priority !== undefined && (updates.priority < 1 || updates.priority > 100)) { - return res.status(400).json({ error: 'Priority must be between 1 and 100' }); + return res.status(400).json({ error: 'Priority must be between 1 and 100' }) } // 验证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"' }); + 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' }); + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) } // 获取账户当前信息以处理分组变更 - const currentAccount = await claudeConsoleAccountService.getAccount(accountId); + const currentAccount = await claudeConsoleAccountService.getAccount(accountId) if (!currentAccount) { - return res.status(404).json({ error: 'Account not found' }); + return res.status(404).json({ error: 'Account not found' }) } // 处理分组的变更 if (updates.accountType !== undefined) { // 如果之前是分组类型,需要从原分组中移除 if (currentAccount.accountType === 'group') { - const oldGroup = await accountGroupService.getAccountGroup(accountId); + const oldGroup = await accountGroupService.getAccountGroup(accountId) if (oldGroup) { - await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id); + await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) } } // 如果新类型是分组,添加到新分组 if (updates.accountType === 'group' && updates.groupId) { // Claude Console 账户在分组中被视为 'claude' 平台 - await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude'); + await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude') } } - await claudeConsoleAccountService.updateAccount(accountId, updates); - - logger.success(`📝 Admin updated Claude Console account: ${accountId}`); - res.json({ success: true, message: 'Claude Console account updated successfully' }); + await claudeConsoleAccountService.updateAccount(accountId, updates) + + 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); - res.status(500).json({ error: 'Failed to update Claude Console account', message: error.message }); + 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; - + const { accountId } = req.params + // 获取账户信息以检查是否在分组中 - const account = await claudeConsoleAccountService.getAccount(accountId); + const account = await claudeConsoleAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const group = await accountGroupService.getAccountGroup(accountId); + const group = await accountGroupService.getAccountGroup(accountId) if (group) { - await accountGroupService.removeAccountFromGroup(accountId, group.id); + await accountGroupService.removeAccountFromGroup(accountId, group.id) } } - - await claudeConsoleAccountService.deleteAccount(accountId); - - logger.success(`🗑️ Admin deleted Claude Console account: ${accountId}`); - res.json({ success: true, message: 'Claude Console account deleted successfully' }); - } catch (error) { - logger.error('❌ Failed to delete Claude Console account:', error); - res.status(500).json({ error: 'Failed to delete Claude Console account', message: error.message }); - } -}); + await claudeConsoleAccountService.deleteAccount(accountId) + + logger.success(`🗑️ Admin deleted Claude Console account: ${accountId}`) + return res.json({ success: true, message: 'Claude Console account deleted successfully' }) + } 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); + const { accountId } = req.params + + const account = await claudeConsoleAccountService.getAccount(accountId) if (!account) { - return res.status(404).json({ error: 'Account not found' }); + 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'}`); - res.json({ success: true, isActive: newStatus }); + + 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); - res.status(500).json({ error: 'Failed to toggle account status', message: error.message }); + 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' }); +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 }) + + 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 }) } - - const newSchedulable = !account.schedulable; - await claudeConsoleAccountService.updateAccount(accountId, { schedulable: newSchedulable }); - - logger.success(`🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`); - res.json({ success: true, schedulable: newSchedulable }); - } catch (error) { - logger.error('❌ Failed to toggle Claude Console account schedulable status:', error); - res.status(500).json({ error: 'Failed to toggle schedulable status', message: error.message }); } -}); +) // ☁️ Bedrock 账户管理 // 获取所有Bedrock账户 router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { try { - const result = await bedrockAccountService.getAllAccounts(); + const result = await bedrockAccountService.getAllAccounts() if (!result.success) { - return res.status(500).json({ error: 'Failed to get Bedrock accounts', message: result.error }); + return res + .status(500) + .json({ error: 'Failed to get Bedrock accounts', message: result.error }) } - + // 为每个账户添加使用统计信息 - const accountsWithStats = await Promise.all(result.data.map(async (account) => { - try { - const usageStats = await redis.getAccountUsageStats(account.id); - return { - ...account, - usage: { - daily: usageStats.daily, - total: usageStats.total, - averages: usageStats.averages + const accountsWithStats = await Promise.all( + result.data.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id) + return { + ...account, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } } - }; - } catch (statsError) { - logger.warn(`⚠️ Failed to get usage stats for Bedrock account ${account.id}:`, statsError.message); - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + } catch (statsError) { + logger.warn( + `⚠️ Failed to get usage stats for Bedrock account ${account.id}:`, + statsError.message + ) + return { + ...account, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } } - }; - } - })); - - res.json({ success: true, data: accountsWithStats }); + } + }) + ) + + return res.json({ success: true, data: accountsWithStats }) } catch (error) { - logger.error('❌ Failed to get Bedrock accounts:', error); - res.status(500).json({ error: 'Failed to get Bedrock accounts', message: error.message }); + 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) => { @@ -1416,25 +1560,29 @@ router.post('/bedrock-accounts', authenticateAdmin, async (req, res) => { priority, accountType, credentialType - } = req.body; + } = req.body if (!name) { - return res.status(400).json({ error: 'Name is required' }); + 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' }); + 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"' }); + 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"' }); + return res.status(400).json({ + error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"' + }) } const result = await bedrockAccountService.createAccount({ @@ -1446,468 +1594,541 @@ router.post('/bedrock-accounts', authenticateAdmin, async (req, res) => { 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 }); + return res + .status(500) + .json({ error: 'Failed to create Bedrock account', message: result.error }) } - logger.success(`☁️ Admin created Bedrock account: ${name}`); - res.json({ success: true, data: result.data }); + logger.success(`☁️ Admin created Bedrock account: ${name}`) + return res.json({ success: true, data: result.data }) } catch (error) { - logger.error('❌ Failed to create Bedrock account:', error); - res.status(500).json({ error: 'Failed to create Bedrock account', message: error.message }); + 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; + const { accountId } = req.params + const updates = req.body // 验证priority的有效性(1-100) if (updates.priority !== undefined && (updates.priority < 1 || updates.priority > 100)) { - return res.status(400).json({ error: 'Priority must be between 1 and 100' }); + return res.status(400).json({ error: 'Priority must be between 1 and 100' }) } // 验证accountType的有效性 if (updates.accountType && !['shared', 'dedicated'].includes(updates.accountType)) { - return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' }); + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared" or "dedicated"' }) } // 验证credentialType的有效性 - if (updates.credentialType && !['default', 'access_key', 'bearer_token'].includes(updates.credentialType)) { - return res.status(400).json({ error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"' }); + if ( + updates.credentialType && + !['default', 'access_key', 'bearer_token'].includes(updates.credentialType) + ) { + return res.status(400).json({ + error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"' + }) } - const result = await bedrockAccountService.updateAccount(accountId, updates); + const result = await bedrockAccountService.updateAccount(accountId, updates) if (!result.success) { - return res.status(500).json({ error: 'Failed to update Bedrock account', message: result.error }); + return res + .status(500) + .json({ error: 'Failed to update Bedrock account', message: result.error }) } - - logger.success(`📝 Admin updated Bedrock account: ${accountId}`); - res.json({ success: true, message: 'Bedrock account updated successfully' }); + + 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); - res.status(500).json({ error: 'Failed to update Bedrock account', message: error.message }); + 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; - - const result = await bedrockAccountService.deleteAccount(accountId); + const { accountId } = req.params + + const result = await bedrockAccountService.deleteAccount(accountId) if (!result.success) { - return res.status(500).json({ error: 'Failed to delete Bedrock account', message: result.error }); + return res + .status(500) + .json({ error: 'Failed to delete Bedrock account', message: result.error }) } - - logger.success(`🗑️ Admin deleted Bedrock account: ${accountId}`); - res.json({ success: true, message: 'Bedrock account deleted successfully' }); + + logger.success(`🗑️ Admin deleted Bedrock account: ${accountId}`) + return res.json({ success: true, message: 'Bedrock account deleted successfully' }) } catch (error) { - logger.error('❌ Failed to delete Bedrock account:', error); - res.status(500).json({ error: 'Failed to delete Bedrock account', message: error.message }); + 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); + const { accountId } = req.params + + const accountResult = await bedrockAccountService.getAccount(accountId) if (!accountResult.success) { - return res.status(404).json({ error: 'Account not found' }); + return res.status(404).json({ error: 'Account not found' }) } - - const newStatus = !accountResult.data.isActive; - const updateResult = await bedrockAccountService.updateAccount(accountId, { isActive: newStatus }); + + 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 }); + 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'}`); - res.json({ success: true, isActive: newStatus }); + + 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); - res.status(500).json({ error: 'Failed to toggle account status', message: error.message }); + 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 }); +router.put( + '/bedrock-accounts/:accountId/toggle-schedulable', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params - if (!updateResult.success) { - return res.status(500).json({ error: 'Failed to toggle schedulable status', message: updateResult.error }); + 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 }) + } + + 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 }) } - - logger.success(`🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`); - res.json({ success: true, schedulable: newSchedulable }); - } catch (error) { - logger.error('❌ Failed to toggle Bedrock account schedulable status:', error); - 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); + 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 }); + return res.status(500).json({ error: 'Account test failed', message: result.error }) } - - logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`); - res.json({ success: true, data: result.data }); + + 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); - res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message }); + 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 } = req.body; - + const { state } = 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); - + 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) + // 创建 OAuth 会话,包含 codeVerifier - const sessionId = authState; + const sessionId = authState await redis.setOAuthSession(sessionId, { state: authState, type: 'gemini', redirectUri: finalRedirectUri, - codeVerifier: codeVerifier, // 保存 PKCE code verifier + codeVerifier, // 保存 PKCE code verifier createdAt: new Date().toISOString() - }); - - logger.info(`Generated Gemini OAuth URL with session: ${sessionId}`); - res.json({ - success: true, - data: { + }) + + 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); - res.status(500).json({ error: 'Failed to generate auth URL', message: error.message }); + 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; - + const { sessionId } = req.body + if (!sessionId) { - return res.status(400).json({ error: 'Session ID is required' }); + return res.status(400).json({ error: 'Session ID is required' }) } - - const result = await geminiAccountService.pollAuthorizationStatus(sessionId); - + + const result = await geminiAccountService.pollAuthorizationStatus(sessionId) + if (result.success) { - logger.success(`✅ Gemini OAuth authorization successful for session: ${sessionId}`); - res.json({ success: true, data: { tokens: result.tokens } }); + logger.success(`✅ Gemini OAuth authorization successful for session: ${sessionId}`) + return res.json({ success: true, data: { tokens: result.tokens } }) } else { - res.json({ success: false, error: result.error }); + return res.json({ success: false, error: result.error }) } } catch (error) { - logger.error('❌ Failed to poll Gemini auth status:', error); - res.status(500).json({ error: 'Failed to poll auth status', message: error.message }); + 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 } = req.body; - + const { code, sessionId } = req.body + if (!code) { - return res.status(400).json({ error: 'Authorization code is required' }); + return res.status(400).json({ error: 'Authorization code is required' }) } - - let redirectUri = 'https://codeassist.google.com/authcode'; - let codeVerifier = null; - + + let redirectUri = 'https://codeassist.google.com/authcode' + let codeVerifier = null + // 如果提供了 sessionId,从 OAuth 会话中获取信息 if (sessionId) { - const sessionData = await redis.getOAuthSession(sessionId); + const sessionData = await redis.getOAuthSession(sessionId) if (sessionData) { - redirectUri = sessionData.redirectUri || redirectUri; - codeVerifier = sessionData.codeVerifier; - logger.info(`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}`); + const { redirectUri: sessionRedirectUri, codeVerifier: sessionCodeVerifier } = sessionData + redirectUri = sessionRedirectUri || redirectUri + codeVerifier = sessionCodeVerifier + logger.info( + `Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}` + ) } } - - const tokens = await geminiAccountService.exchangeCodeForTokens(code, redirectUri, codeVerifier); - + + const tokens = await geminiAccountService.exchangeCodeForTokens(code, redirectUri, codeVerifier) + // 清理 OAuth 会话 if (sessionId) { - await redis.deleteOAuthSession(sessionId); + await redis.deleteOAuthSession(sessionId) } - - logger.success('✅ Successfully exchanged Gemini authorization code'); - res.json({ success: true, data: { tokens } }); + + 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); - res.status(500).json({ error: 'Failed to exchange code', message: error.message }); + 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 accounts = await geminiAccountService.getAllAccounts(); - + const accounts = await geminiAccountService.getAllAccounts() + // 为每个账户添加使用统计信息(与Claude账户相同的逻辑) - const accountsWithStats = await Promise.all(accounts.map(async (account) => { - try { - const usageStats = await redis.getAccountUsageStats(account.id); - return { - ...account, - usage: { - daily: usageStats.daily, - total: usageStats.total, - averages: usageStats.averages + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id) + return { + ...account, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } } - }; - } catch (statsError) { - logger.warn(`⚠️ Failed to get usage stats for Gemini account ${account.id}:`, statsError.message); - // 如果获取统计失败,返回空统计 - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + } catch (statsError) { + logger.warn( + `⚠️ Failed to get usage stats for Gemini account ${account.id}:`, + statsError.message + ) + // 如果获取统计失败,返回空统计 + return { + ...account, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } } - }; - } - })); - - res.json({ success: true, data: accountsWithStats }); + } + }) + ) + + return res.json({ success: true, data: accountsWithStats }) } catch (error) { - logger.error('❌ Failed to get Gemini accounts:', error); - res.status(500).json({ error: 'Failed to get accounts', message: error.message }); + 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; - + const accountData = req.body + // 输入验证 if (!accountData.name) { - return res.status(400).json({ error: 'Account name is required' }); + 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"' }); + 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' }); + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) } - - const newAccount = await geminiAccountService.createAccount(accountData); - + + const newAccount = await geminiAccountService.createAccount(accountData) + // 如果是分组类型,将账户添加到分组 if (accountData.accountType === 'group' && accountData.groupId) { - await accountGroupService.addAccountToGroup(newAccount.id, accountData.groupId, 'gemini'); + await accountGroupService.addAccountToGroup(newAccount.id, accountData.groupId, 'gemini') } - - logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`); - res.json({ success: true, data: newAccount }); + + logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`) + return res.json({ success: true, data: newAccount }) } catch (error) { - logger.error('❌ Failed to create Gemini account:', error); - res.status(500).json({ error: 'Failed to create account', message: error.message }); + 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; - + 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"' }); + 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' }); + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) } - + // 获取账户当前信息以处理分组变更 - const currentAccount = await geminiAccountService.getAccount(accountId); + const currentAccount = await geminiAccountService.getAccount(accountId) if (!currentAccount) { - return res.status(404).json({ error: 'Account not found' }); + return res.status(404).json({ error: 'Account not found' }) } - + // 处理分组的变更 if (updates.accountType !== undefined) { // 如果之前是分组类型,需要从原分组中移除 if (currentAccount.accountType === 'group') { - const oldGroup = await accountGroupService.getAccountGroup(accountId); + const oldGroup = await accountGroupService.getAccountGroup(accountId) if (oldGroup) { - await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id); + await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) } } // 如果新类型是分组,添加到新分组 if (updates.accountType === 'group' && updates.groupId) { - await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'gemini'); + await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'gemini') } } - - const updatedAccount = await geminiAccountService.updateAccount(accountId, updates); - - logger.success(`📝 Admin updated Gemini account: ${accountId}`); - res.json({ success: true, data: updatedAccount }); + + const updatedAccount = await geminiAccountService.updateAccount(accountId, updates) + + logger.success(`📝 Admin updated Gemini account: ${accountId}`) + return res.json({ success: true, data: updatedAccount }) } catch (error) { - logger.error('❌ Failed to update Gemini account:', error); - res.status(500).json({ error: 'Failed to update account', message: error.message }); + 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; - + const { accountId } = req.params + // 获取账户信息以检查是否在分组中 - const account = await geminiAccountService.getAccount(accountId); + const account = await geminiAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const group = await accountGroupService.getAccountGroup(accountId); + const group = await accountGroupService.getAccountGroup(accountId) if (group) { - await accountGroupService.removeAccountFromGroup(accountId, group.id); + await accountGroupService.removeAccountFromGroup(accountId, group.id) } } - - await geminiAccountService.deleteAccount(accountId); - - logger.success(`🗑️ Admin deleted Gemini account: ${accountId}`); - res.json({ success: true, message: 'Gemini account deleted successfully' }); + + await geminiAccountService.deleteAccount(accountId) + + logger.success(`🗑️ Admin deleted Gemini account: ${accountId}`) + return res.json({ success: true, message: 'Gemini account deleted successfully' }) } catch (error) { - logger.error('❌ Failed to delete Gemini account:', error); - res.status(500).json({ error: 'Failed to delete account', message: error.message }); + 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}`); - res.json({ success: true, data: result }); + 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); - res.status(500).json({ error: 'Failed to refresh token', message: error.message }); + 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' }); +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' }) + } + + // 将字符串 'true'/'false' 转换为布尔值,然后取反 + const currentSchedulable = account.schedulable === 'true' + const newSchedulable = !currentSchedulable + + await geminiAccountService.updateAccount(accountId, { schedulable: String(newSchedulable) }) + + logger.success( + `🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}` + ) + return res.json({ success: true, schedulable: newSchedulable }) + } 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 }) } - - // 将字符串 'true'/'false' 转换为布尔值,然后取反 - const currentSchedulable = account.schedulable === 'true'; - const newSchedulable = !currentSchedulable; - - await geminiAccountService.updateAccount(accountId, { schedulable: String(newSchedulable) }); - - logger.success(`🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`); - res.json({ success: true, schedulable: newSchedulable }); - } catch (error) { - logger.error('❌ Failed to toggle Gemini account schedulable status:', error); - 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(); - - res.json({ + 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) + activeToday: accountsStats.filter((account) => account.daily.requests > 0).length, + totalDailyTokens: accountsStats.reduce( + (sum, account) => sum + (account.daily.allTokens || 0), + 0 + ), + totalDailyRequests: accountsStats.reduce( + (sum, account) => sum + (account.daily.requests || 0), + 0 + ) }, timestamp: new Date().toISOString() - }); + }) } catch (error) { - logger.error('❌ Failed to get accounts usage stats:', error); - res.status(500).json({ + 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 { accountId } = req.params + const accountStats = await redis.getAccountUsageStats(accountId) + // 获取账户基本信息 - const accountData = await claudeAccountService.getAccount(accountId); + const accountData = await claudeAccountService.getAccount(accountId) if (!accountData) { return res.status(404).json({ success: false, error: 'Account not found' - }); + }) } - - res.json({ + + return res.json({ success: true, data: { ...accountStats, @@ -1920,23 +2141,32 @@ router.get('/accounts/:accountId/usage-stats', authenticateAdmin, async (req, re } }, timestamp: new Date().toISOString() - }); + }) } catch (error) { - logger.error('❌ Failed to get account usage stats:', error); - res.status(500).json({ + 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 - }); + }) } -}); +}) // 📊 系统统计 // 获取系统概览 router.get('/dashboard', authenticateAdmin, async (req, res) => { try { - const [, apiKeys, claudeAccounts, claudeConsoleAccounts, geminiAccounts, todayStats, systemAverages, realtimeMetrics] = await Promise.all([ + const [ + , + apiKeys, + claudeAccounts, + claudeConsoleAccounts, + geminiAccounts, + todayStats, + systemAverages, + realtimeMetrics + ] = await Promise.all([ redis.getSystemStats(), apiKeyService.getAllApiKeys(), claudeAccountService.getAllAccounts(), @@ -1945,24 +2175,57 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { redis.getTodayStats(), redis.getSystemAverages(), redis.getRealtimeSystemMetrics() - ]); + ]) // 计算使用统计(统一使用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; - const activeClaudeAccounts = claudeAccounts.filter(acc => acc.isActive && acc.status === 'active').length; - const rateLimitedClaudeAccounts = claudeAccounts.filter(acc => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited).length; - const activeClaudeConsoleAccounts = claudeConsoleAccounts.filter(acc => acc.isActive && acc.status === 'active').length; - const rateLimitedClaudeConsoleAccounts = claudeConsoleAccounts.filter(acc => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited).length; - const activeGeminiAccounts = geminiAccounts.filter(acc => acc.isActive && acc.status === 'active').length; - const rateLimitedGeminiAccounts = geminiAccounts.filter(acc => acc.rateLimitStatus === 'limited').length; + 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 + const activeClaudeAccounts = claudeAccounts.filter( + (acc) => acc.isActive && acc.status === 'active' + ).length + const rateLimitedClaudeAccounts = claudeAccounts.filter( + (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited + ).length + const activeClaudeConsoleAccounts = claudeConsoleAccounts.filter( + (acc) => acc.isActive && acc.status === 'active' + ).length + const rateLimitedClaudeConsoleAccounts = claudeConsoleAccounts.filter( + (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited + ).length + const activeGeminiAccounts = geminiAccounts.filter( + (acc) => acc.isActive && acc.status === 'active' + ).length + const rateLimitedGeminiAccounts = geminiAccounts.filter( + (acc) => acc.rateLimitStatus === 'limited' + ).length const dashboard = { overview: { @@ -1972,8 +2235,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { activeClaudeAccounts: activeClaudeAccounts + activeClaudeConsoleAccounts, rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts, totalGeminiAccounts: geminiAccounts.length, - activeGeminiAccounts: activeGeminiAccounts, - rateLimitedGeminiAccounts: rateLimitedGeminiAccounts, + activeGeminiAccounts, + rateLimitedGeminiAccounts, totalTokensUsed, totalRequestsUsed, totalInputTokensUsed, @@ -2003,131 +2266,138 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { }, systemHealth: { redisConnected: redis.isConnected, - claudeAccountsHealthy: (activeClaudeAccounts + activeClaudeConsoleAccounts) > 0, + claudeAccountsHealthy: activeClaudeAccounts + activeClaudeConsoleAccounts > 0, geminiAccountsHealthy: activeGeminiAccounts > 0, uptime: process.uptime() }, systemTimezone: config.system.timezoneOffset || 8 - }; + } - res.json({ success: true, data: dashboard }); + return res.json({ success: true, data: dashboard }) } catch (error) { - logger.error('❌ Failed to get dashboard data:', error); - res.status(500).json({ error: 'Failed to get dashboard data', message: error.message }); + 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 - + const { period = 'daily' } = req.query // daily, monthly + // 获取基础API Key统计 - const apiKeys = await apiKeyService.getAllApiKeys(); - - const stats = apiKeys.map(key => ({ + const apiKeys = await apiKeyService.getAllApiKeys() + + const stats = apiKeys.map((key) => ({ keyId: key.id, keyName: key.name, usage: key.usage - })); + })) - res.json({ success: true, data: { period, stats } }); + return res.json({ success: true, data: { period, stats } }) } catch (error) { - logger.error('❌ Failed to get usage stats:', error); - res.status(500).json({ error: 'Failed to get usage stats', message: error.message }); + 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(); - + 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 = []; - + let searchPatterns = [] + if (startDate && endDate) { // 自定义日期范围,生成多个日期的搜索模式 - const start = new Date(startDate); - const end = new Date(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' }); + return res.status(400).json({ error: 'Start date must be before or equal to end date' }) } - + // 限制最大范围为31天 - const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1; + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 if (daysDiff > 31) { - return res.status(400).json({ error: 'Date range cannot exceed 31 days' }); + return res.status(400).json({ error: 'Date range cannot exceed 31 days' }) } - + // 生成日期范围内所有日期的搜索模式 - const currentDate = new Date(start); + const currentDate = new Date(start) while (currentDate <= end) { - const dateStr = redis.getDateStringInTimezone(currentDate); - searchPatterns.push(`usage:model:daily:*:${dateStr}`); - currentDate.setDate(currentDate.getDate() + 1); + 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`); + + 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]; + const pattern = + period === 'daily' + ? `usage:model:daily:*:${today}` + : `usage:model:monthly:*:${currentMonth}` + searchPatterns = [pattern] } - - logger.info('📊 Searching patterns:', searchPatterns); - + + logger.info('📊 Searching patterns:', searchPatterns) + // 获取所有匹配的keys - const allKeys = []; + const allKeys = [] for (const pattern of searchPatterns) { - const keys = await client.keys(pattern); - allKeys.push(...keys); + const keys = await client.keys(pattern) + allKeys.push(...keys) } - - logger.info(`📊 Found ${allKeys.length} matching keys in total`); - + + logger.info(`📊 Found ${allKeys.length} matching keys in total`) + // 模型名标准化函数(与redis.js保持一致) const normalizeModelName = (model) => { - if (!model || model === 'unknown') return 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; + 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$/, ''); - }; + return model.replace(/-v\d+:\d+$|:latest$/, '') + } // 聚合相同模型的数据 - const modelStatsMap = new Map(); - + const modelStatsMap = new Map() + for (const key of allKeys) { - const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/); - + const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) + if (!match) { - logger.warn(`📊 Pattern mismatch for key: ${key}`); - continue; + logger.warn(`📊 Pattern mismatch for key: ${key}`) + continue } - - const rawModel = match[1]; - const normalizedModel = normalizeModelName(rawModel); - const data = await client.hgetall(key); - + + 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, @@ -2136,33 +2406,33 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => { 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); + } + + 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 = []; - + 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); - + const costData = CostCalculator.calculateCost(usage, model) + modelStats.push({ model, period: startDate && endDate ? 'custom' : period, @@ -2178,25 +2448,32 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => { 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 + totalTokens: + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens }, costs: costData.costs, formatted: costData.formatted, pricing: costData.pricing - }); + }) } - + // 按总费用排序 - modelStats.sort((a, b) => b.costs.total - a.costs.total); - - logger.info(`📊 Returning ${modelStats.length} global model stats for period ${period}:`, modelStats); - - res.json({ success: true, data: modelStats }); + 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); - res.status(500).json({ error: 'Failed to get model stats', message: error.message }); + logger.error('❌ Failed to get model stats:', error) + return res.status(500).json({ error: 'Failed to get model stats', message: error.message }) } -}); +}) // 🔧 系统管理 @@ -2206,150 +2483,154 @@ router.post('/cleanup', authenticateAdmin, async (req, res) => { const [expiredKeys, errorAccounts] = await Promise.all([ apiKeyService.cleanupExpiredKeys(), claudeAccountService.cleanupErrorAccounts() - ]); - - await redis.cleanup(); - - logger.success(`🧹 Admin triggered cleanup: ${expiredKeys} expired keys, ${errorAccounts} error accounts`); - - res.json({ + ]) + + 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); - res.status(500).json({ error: 'Cleanup failed', message: error.message }); + 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 = []; - + const { days = 7, granularity = 'day', startDate, endDate } = req.query + const client = redis.getClientSafe() + + const trendData = [] + if (granularity === 'hour') { // 小时粒度统计 - let startTime, endTime; - + let startTime, endTime + if (startDate && endDate) { // 使用自定义时间范围 - startTime = new Date(startDate); - endTime = new Date(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}`); + 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); + endTime = new Date() + startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000) } - + // 确保时间范围不超过24小时 - const timeDiff = endTime - startTime; + const timeDiff = endTime - startTime if (timeDiff > 24 * 60 * 60 * 1000) { - return res.status(400).json({ - error: '小时粒度查询时间范围不能超过24小时' - }); + return res.status(400).json({ + error: '小时粒度查询时间范围不能超过24小时' + }) } - + // 按小时遍历 - const currentHour = new Date(startTime); - currentHour.setMinutes(0, 0, 0); - + 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 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; - + 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); - + 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 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; + } + 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); - + const pattern = `usage:hourly:*:${hourKey}` + const keys = await client.keys(pattern) + for (const key of keys) { - const data = await client.hgetall(key); + 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; + 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 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'); - + 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时间用于排序 @@ -2359,197 +2640,204 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => { requests: hourRequests, cacheCreateTokens: hourCacheCreateTokens, cacheReadTokens: hourCacheReadTokens, - totalTokens: hourInputTokens + hourOutputTokens + hourCacheCreateTokens + hourCacheReadTokens, + totalTokens: + hourInputTokens + hourOutputTokens + hourCacheCreateTokens + hourCacheReadTokens, cost: hourCost - }); - + }) + // 移到下一个小时 - currentHour.setHours(currentHour.getHours() + 1); + currentHour.setHours(currentHour.getHours() + 1) } - } else { // 天粒度统计(保持原有逻辑) - const daysCount = parseInt(days) || 7; - const today = new Date(); - + 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); - + 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 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 } } - - // 使用默认模型价格计算 - 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; + + // 如果没有模型级别的数据,回退到原始方法 + 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) + }) } - - 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)); + 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)); + trendData.sort((a, b) => new Date(a.date) - new Date(b.date)) } - - res.json({ success: true, data: trendData, granularity }); + + return res.json({ success: true, data: trendData, granularity }) } catch (error) { - logger.error('❌ Failed to get usage trend:', error); - res.status(500).json({ error: 'Failed to get usage trend', message: error.message }); + 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 = []; - + 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); - + 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' }); + return res.status(400).json({ error: 'Start date must be before or equal to end date' }) } - + // 限制最大范围为31天 - const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1; + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 if (daysDiff > 31) { - return res.status(400).json({ error: 'Date range cannot exceed 31 days' }); + return res.status(400).json({ error: 'Date range cannot exceed 31 days' }) } - + // 生成日期范围内所有日期的搜索模式 for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { - const dateStr = redis.getDateStringInTimezone(d); - searchPatterns.push(`usage:${keyId}:model:daily:*:${dateStr}`); + 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}`); + + 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 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 = []; // 定义结果数组 - + 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`); - + 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}$/); - + 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; + logger.warn(`📊 Pattern mismatch for key: ${key}`) + continue } - - const model = match[1]; - const data = await client.hgetall(key); - + + const model = match[1] + const data = await client.hgetall(key) + if (data && Object.keys(data).length > 0) { // 累加同一模型的数据 if (!modelStatsMap.has(model)) { @@ -2560,34 +2848,34 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) = 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; + + 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); - + 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); - + const costData = CostCalculator.calculateCost(usage, model) + modelStats.push({ model, requests: stats.requests, @@ -2601,42 +2889,47 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) = 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}`); - + 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); - + 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); - + logger.info( + `📊 Found API key usage data from getAllApiKeys for ${keyId}:`, + targetApiKey.usage + ) + // 从汇总数据创建展示条目 - let usageData; + let usageData if (period === 'custom' || period === 'daily') { // 对于自定义或日统计,使用daily数据或total数据 - usageData = targetApiKey.usage.daily || targetApiKey.usage.total; + usageData = targetApiKey.usage.daily || targetApiKey.usage.total } else { // 对于月统计,使用monthly数据或total数据 - usageData = targetApiKey.usage.monthly || targetApiKey.usage.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'); - + const costData = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022') + modelStats.push({ model: '总体使用 (历史数据)', requests: usageData.requests || 0, @@ -2650,104 +2943,107 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) = formatted: costData.formatted, pricing: costData.pricing, usingDynamicPricing: costData.usingDynamicPricing - }); - - logger.info('📊 Generated display data from API key usage stats'); + }) + + logger.info('📊 Generated display data from API key usage stats') } else { - logger.info(`📊 No usage data found for period ${period} in API key data`); + 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`); + logger.info(`📊 API key ${keyId} not found or has no usage data`) } } catch (error) { - logger.error('❌ Error fetching API key usage data:', error); + logger.error('❌ Error fetching API key usage data:', error) } } - - // 按总token数降序排列 - modelStats.sort((a, b) => b.allTokens - a.allTokens); - - logger.info(`📊 Returning ${modelStats.length} model stats for API key ${keyId}:`, modelStats); - - res.json({ success: true, data: modelStats }); - } catch (error) { - logger.error('❌ Failed to get API key model stats:', error); - res.status(500).json({ error: 'Failed to get API key model stats', message: error.message }); - } -}); + // 按总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 }) + } +}) // 获取按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 = []; - + 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])); - + const apiKeys = await apiKeyService.getAllApiKeys() + const apiKeyMap = new Map(apiKeys.map((key) => [key.id, key])) + if (granularity === 'hour') { // 小时粒度统计 - let endTime, startTime; - + let endTime, startTime + if (startDate && endDate) { // 自定义时间范围 - startTime = new Date(startDate); - endTime = new Date(endDate); + startTime = new Date(startDate) + endTime = new Date(endDate) } else { // 默认近24小时 - endTime = new Date(); - startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000); + endTime = new Date() + startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000) } - + // 按小时遍历 - const currentHour = new Date(startTime); - currentHour.setMinutes(0, 0, 0); - + 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 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 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 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(); + 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); - + 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; - + 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, @@ -2756,106 +3052,109 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { outputTokens, cacheCreateTokens, cacheReadTokens - }); + }) } } - + // 获取该小时的模型级别数据来计算准确费用 - const modelPattern = `usage:*:model:hourly:*:${hourKey}`; - const modelKeys = await client.keys(modelPattern); - const apiKeyCostMap = new Map(); - + 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); - + 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); + } + + 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; - + const cost = apiKeyCostMap.get(apiKeyId) || 0 + // 如果没有模型级别数据,使用默认模型计算(降级方案) - let finalCost = cost; - let formattedCost = CostCalculator.formatCost(cost); - + 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; + } + 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: formattedCost - }; + formattedCost + } } - - trendData.push(hourData); - currentHour.setHours(currentHour.getHours() + 1); + + trendData.push(hourData) + currentHour.setHours(currentHour.getHours() + 1) } - } else { // 天粒度统计 - const daysCount = parseInt(days) || 7; - const today = new Date(); - + 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); - + 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 pattern = `usage:daily:*:${dateStr}` + const keys = await client.keys(pattern) + const dayData = { date: dateStr, apiKeys: {} - }; - + } + // 先收集基础数据 - const apiKeyDataMap = new Map(); + 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); - + 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; - + 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, @@ -2864,175 +3163,183 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { outputTokens, cacheCreateTokens, cacheReadTokens - }); + }) } } - + // 获取该天的模型级别数据来计算准确费用 - const modelPattern = `usage:*:model:daily:*:${dateStr}`; - const modelKeys = await client.keys(modelPattern); - const apiKeyCostMap = new Map(); - + 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); - + 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); + } + + 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; - + const cost = apiKeyCostMap.get(apiKeyId) || 0 + // 如果没有模型级别数据,使用默认模型计算(降级方案) - let finalCost = cost; - let formattedCost = CostCalculator.formatCost(cost); - + 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; + } + 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: formattedCost - }; + formattedCost + } } - - trendData.push(dayData); + + trendData.push(dayData) } } - + // 按时间正序排列 if (granularity === 'hour') { - trendData.sort((a, b) => new Date(a.hour) - new Date(b.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)); + trendData.sort((a, b) => new Date(a.date) - new Date(b.date)) } - + // 计算每个API Key的总token数,用于排序 - const apiKeyTotals = new Map(); + 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); + apiKeyTotals.set(apiKeyId, (apiKeyTotals.get(apiKeyId) || 0) + data.tokens) } } - + // 获取前10个使用量最多的API Key const topApiKeys = Array.from(apiKeyTotals.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 10) - .map(([apiKeyId]) => apiKeyId); - - res.json({ - success: true, - data: trendData, + .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); - res.status(500).json({ error: 'Failed to get API keys usage trend', message: error.message }); + 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}`); - + 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; - + 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; + 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$/, ''); - }; - + return model.replace(/-v\d+:\d+$|:latest$/, '') + } + // 获取所有API Keys的使用统计 - const apiKeys = await apiKeyService.getAllApiKeys(); - - let totalCosts = { + const apiKeys = await apiKeyService.getAllApiKeys() + + const totalCosts = { inputCost: 0, outputCost: 0, cacheCreateCost: 0, cacheReadCost: 0, totalCost: 0 - }; - - let modelCosts = {}; - + } + + 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; + 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}`; + pattern = `usage:model:daily:*:${today}` } else if (period === 'monthly') { - pattern = `usage:model:monthly:*:${currentMonth}`; + pattern = `usage:model:monthly:*:${currentMonth}` } else if (period === '7days') { // 最近7天:汇总daily数据 - const modelUsageMap = new Map(); - + const modelUsageMap = new Map() + // 获取最近7天的所有daily统计数据 for (let i = 0; i < 7; i++) { - const date = new Date(); - date.setDate(date.getDate() - i); - const tzDate = redis.getDateInTimezone(date); - const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}`; - const dayPattern = `usage:model:daily:*:${dateStr}`; - - const dayKeys = await client.keys(dayPattern); - + 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); - + 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, { @@ -3040,38 +3347,40 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { 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; + + 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`); - + 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}`); - + } + + 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, @@ -3080,9 +3389,9 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { costs: costResult.costs, formatted: costResult.formatted, usingDynamicPricing: costResult.usingDynamicPricing - }; + } } - + // 返回7天统计结果 return res.json({ success: true, @@ -3100,24 +3409,26 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { }, 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`); - + 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(); - + const modelUsageMap = new Map() + for (const key of allModelKeys) { // 解析模型名称(只处理monthly数据) - let modelMatch = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/); - if (!modelMatch) continue; - - const model = modelMatch[1]; - const data = await client.hgetall(key); - + 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, { @@ -3125,37 +3436,39 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { 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; + + 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`); - + 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}`); - + } + + 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, @@ -3164,12 +3477,12 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { 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'); - + 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 = { @@ -3177,20 +3490,20 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { 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; + const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022') + totalCosts.inputCost += costResult.costs.input + totalCosts.outputCost += costResult.costs.output + totalCosts.cacheCreateCost += costResult.costs.cacheWrite + totalCosts.cacheReadCost += costResult.costs.cacheRead + totalCosts.totalCost += costResult.costs.total } } } - - res.json({ + + return res.json({ success: true, data: { period, @@ -3207,41 +3520,43 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { modelCosts: Object.values(modelCosts).sort((a, b) => b.costs.total - a.costs.total), pricingServiceStatus: pricingService.getStatus() } - }); - return; + }) } - + // 对于今日或本月,从Redis获取详细的模型统计 - const keys = await client.keys(pattern); - + 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); - + 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); - + } + + 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; - + 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, @@ -3250,11 +3565,11 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { costs: costResult.costs, formatted: costResult.formatted, usingDynamicPricing: costResult.usingDynamicPricing - }; + } } } - - res.json({ + + return res.json({ success: true, data: { period, @@ -3271,25 +3586,27 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { modelCosts: Object.values(modelCosts).sort((a, b) => b.costs.total - a.costs.total), pricingServiceStatus: pricingService.getStatus() } - }); + }) } catch (error) { - logger.error('❌ Failed to calculate usage costs:', error); - res.status(500).json({ error: 'Failed to calculate usage costs', message: error.message }); + 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(); - + const allHeaders = await claudeCodeHeadersService.getAllAccountHeaders() + // 获取所有 Claude 账号信息 - const accounts = await claudeAccountService.getAllAccounts(); - const accountMap = {}; - accounts.forEach(account => { - accountMap[account.id] = account.name; - }); - + const accounts = await claudeAccountService.getAllAccounts() + const accountMap = {} + accounts.forEach((account) => { + accountMap[account.id] = account.name + }) + // 格式化输出 const formattedData = Object.entries(allHeaders).map(([accountId, data]) => ({ accountId, @@ -3298,107 +3615,112 @@ router.get('/claude-code-headers', authenticateAdmin, async (req, res) => { userAgent: data.headers['user-agent'], updatedAt: data.updatedAt, headers: data.headers - })); - - res.json({ + })) + + return res.json({ success: true, data: formattedData - }); + }) } catch (error) { - logger.error('❌ Failed to get Claude Code headers:', error); - res.status(500).json({ error: 'Failed to get Claude Code headers', message: error.message }); + 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); - - res.json({ + 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); - res.status(500).json({ error: 'Failed to clear Claude Code headers', message: error.message }); + 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'; + const versionPath = path.join(__dirname, '../../VERSION') + let currentVersion = '1.0.0' try { - currentVersion = fs.readFileSync(versionPath, 'utf8').trim(); + currentVersion = fs.readFileSync(versionPath, 'utf8').trim() } catch (err) { - logger.warn('⚠️ Could not read VERSION file:', err.message); + logger.warn('⚠️ Could not read VERSION file:', err.message) } try { - // 从缓存获取 - const cacheKey = 'version_check_cache'; - const cached = await redis.getClient().get(cacheKey); - + 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; - + const cachedData = JSON.parse(cached) + const cacheAge = Date.now() - cachedData.timestamp + // 缓存有效期1小时 if (cacheAge < 3600000) { // 实时计算 hasUpdate,不使用缓存的值 - const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0; - + const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0 + return res.json({ success: true, data: { current: currentVersion, latest: cachedData.latest, - hasUpdate: hasUpdate, // 实时计算,不用缓存 + 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 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 release = response.data; - const latestVersion = release.tag_name.replace(/^v/, ''); - // 比较版本 - const hasUpdate = compareVersions(currentVersion, latestVersion) < 0; - + 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小时过期 + await redis.getClient().set( + cacheKey, + JSON.stringify({ + latest: latestVersion, + releaseInfo, + timestamp: Date.now() + }), + 'EX', + 3600 + ) // 1小时过期 - res.json({ + return res.json({ success: true, data: { current: currentVersion, @@ -3407,23 +3729,24 @@ router.get('/check-updates', authenticateAdmin, async (req, res) => { 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, + 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); - + } + + logger.error('❌ Failed to check for updates:', errorDetails.message) + // 处理 404 错误 - 仓库或版本不存在 if (error.response && error.response.status === 404) { return res.json({ @@ -3440,35 +3763,35 @@ router.get('/check-updates', authenticateAdmin, async (req, res) => { }, 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); - + const cacheKey = 'version_check_cache' + const cached = await redis.getClient().get(cacheKey) + if (cached) { - const cachedData = JSON.parse(cached); + const cachedData = JSON.parse(cached) // 实时计算 hasUpdate - const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0; - + const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0 + return res.json({ success: true, data: { current: currentVersion, latest: cachedData.latest, - hasUpdate: hasUpdate, // 实时计算 + hasUpdate, // 实时计算 releaseInfo: cachedData.releaseInfo, cached: true, warning: 'Using cached data due to network error' } - }); + }) } } - + // 其他错误返回当前版本信息 - res.json({ + return res.json({ success: true, data: { current: currentVersion, @@ -3483,31 +3806,31 @@ router.get('/check-updates', authenticateAdmin, async (req, res) => { error: true, warning: error.message || 'Failed to check for updates' } - }); + }) } -}); +}) // 版本比较函数 function compareVersions(current, latest) { const parseVersion = (v) => { - const parts = v.split('.').map(Number); + 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); - + } + } + + const currentV = parseVersion(current) + const latestV = parseVersion(latest) + if (currentV.major !== latestV.major) { - return currentV.major - latestV.major; + return currentV.major - latestV.major } if (currentV.minor !== latestV.minor) { - return currentV.minor - latestV.minor; + return currentV.minor - latestV.minor } - return currentV.patch - latestV.patch; + return currentV.patch - latestV.patch } // 🎨 OEM设置管理 @@ -3515,86 +3838,87 @@ function compareVersions(current, latest) { // 获取OEM设置(公开接口,用于显示) router.get('/oem-settings', async (req, res) => { try { - const client = redis.getClient(); - const oemSettings = await client.get('oem:settings'); - + const client = redis.getClient() + const oemSettings = await client.get('oem:settings') + // 默认设置 const defaultSettings = { siteName: 'Claude Relay Service', siteIcon: '', siteIconData: '', // Base64编码的图标数据 updatedAt: new Date().toISOString() - }; - - let settings = defaultSettings; + } + + let settings = defaultSettings if (oemSettings) { try { - settings = { ...defaultSettings, ...JSON.parse(oemSettings) }; + settings = { ...defaultSettings, ...JSON.parse(oemSettings) } } catch (err) { - logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message); + logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message) } } - - res.json({ + + return res.json({ success: true, data: settings - }); + }) } catch (error) { - logger.error('❌ Failed to get OEM settings:', error); - res.status(500).json({ error: 'Failed to get OEM settings', message: error.message }); + 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 } = req.body; - + const { siteName, siteIcon, siteIconData } = req.body + // 验证输入 if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) { - return res.status(400).json({ error: 'Site name is required' }); + 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' }); + 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' }); + 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); + new URL(siteIcon) } catch (err) { - return res.status(400).json({ error: 'Invalid icon URL format' }); + return res.status(400).json({ error: 'Invalid icon URL format' }) } } - + const settings = { siteName: siteName.trim(), siteIcon: (siteIcon || '').trim(), siteIconData: (siteIconData || '').trim(), // Base64数据 updatedAt: new Date().toISOString() - }; - - const client = redis.getClient(); - await client.set('oem:settings', JSON.stringify(settings)); - - logger.info(`✅ OEM settings updated: ${siteName}`); - - res.json({ + } + + 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); - res.status(500).json({ error: 'Failed to update OEM settings', message: error.message }); + logger.error('❌ Failed to update OEM settings:', error) + return res.status(500).json({ error: 'Failed to update OEM settings', message: error.message }) } -}); +}) -module.exports = router; \ No newline at end of file +module.exports = router diff --git a/src/routes/api.js b/src/routes/api.js index 66843c45..a2ab2562 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -1,360 +1,482 @@ -const express = require('express'); -const claudeRelayService = require('../services/claudeRelayService'); -const claudeConsoleRelayService = require('../services/claudeConsoleRelayService'); -const bedrockRelayService = require('../services/bedrockRelayService'); -const bedrockAccountService = require('../services/bedrockAccountService'); -const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler'); -const apiKeyService = require('../services/apiKeyService'); -const { authenticateApiKey } = require('../middleware/auth'); -const logger = require('../utils/logger'); -const redis = require('../models/redis'); -const sessionHelper = require('../utils/sessionHelper'); +const express = require('express') +const claudeRelayService = require('../services/claudeRelayService') +const claudeConsoleRelayService = require('../services/claudeConsoleRelayService') +const bedrockRelayService = require('../services/bedrockRelayService') +const bedrockAccountService = require('../services/bedrockAccountService') +const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler') +const apiKeyService = require('../services/apiKeyService') +const { authenticateApiKey } = require('../middleware/auth') +const logger = require('../utils/logger') +const redis = require('../models/redis') +const sessionHelper = require('../utils/sessionHelper') -const router = express.Router(); +const router = express.Router() // 🔧 共享的消息处理函数 async function handleMessagesRequest(req, res) { try { - const startTime = Date.now(); - + const startTime = Date.now() + // 严格的输入验证 if (!req.body || typeof req.body !== 'object') { return res.status(400).json({ error: 'Invalid request', message: 'Request body must be a valid JSON object' - }); + }) } if (!req.body.messages || !Array.isArray(req.body.messages)) { return res.status(400).json({ error: 'Invalid request', message: 'Missing or invalid field: messages (must be an array)' - }); + }) } if (req.body.messages.length === 0) { return res.status(400).json({ error: 'Invalid request', message: 'Messages array cannot be empty' - }); + }) } // 检查是否为流式请求 - const isStream = req.body.stream === true; - - logger.api(`🚀 Processing ${isStream ? 'stream' : 'non-stream'} request for key: ${req.apiKey.name}`); + const isStream = req.body.stream === true + + logger.api( + `🚀 Processing ${isStream ? 'stream' : 'non-stream'} request for key: ${req.apiKey.name}` + ) if (isStream) { // 流式响应 - 只使用官方真实usage数据 - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('X-Accel-Buffering', 'no'); // 禁用 Nginx 缓冲 - + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('X-Accel-Buffering', 'no') // 禁用 Nginx 缓冲 + // 禁用 Nagle 算法,确保数据立即发送 if (res.socket && typeof res.socket.setNoDelay === 'function') { - res.socket.setNoDelay(true); + res.socket.setNoDelay(true) } - + // 流式响应不需要额外处理,中间件已经设置了监听器 - - let usageDataCaptured = false; - + + let usageDataCaptured = false + // 生成会话哈希用于sticky会话 - const sessionHash = sessionHelper.generateSessionHash(req.body); - + const sessionHash = sessionHelper.generateSessionHash(req.body) + // 使用统一调度选择账号(传递请求的模型) - const requestedModel = req.body.model; - const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(req.apiKey, sessionHash, requestedModel); - + const requestedModel = req.body.model + const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + // 根据账号类型选择对应的转发服务并调用 if (accountType === 'claude-official') { // 官方Claude账号使用原有的转发服务(会自己选择账号) - await claudeRelayService.relayStreamRequestWithUsageCapture(req.body, req.apiKey, res, req.headers, (usageData) => { - // 回调函数:当检测到完整usage数据时记录真实token使用量 - logger.info('🎯 Usage callback triggered with complete data:', JSON.stringify(usageData, null, 2)); - - if (usageData && usageData.input_tokens !== undefined && usageData.output_tokens !== undefined) { - const inputTokens = usageData.input_tokens || 0; - const outputTokens = usageData.output_tokens || 0; - const cacheCreateTokens = usageData.cache_creation_input_tokens || 0; - const cacheReadTokens = usageData.cache_read_input_tokens || 0; - const model = usageData.model || 'unknown'; - - // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) - const accountId = usageData.accountId; - apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, accountId).catch(error => { - logger.error('❌ Failed to record stream usage:', error); - }); - - // 更新时间窗口内的token计数 - if (req.rateLimitInfo) { - const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; - redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens).catch(error => { - logger.error('❌ Failed to update rate limit token count:', error); - }); - logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`); + await claudeRelayService.relayStreamRequestWithUsageCapture( + req.body, + req.apiKey, + res, + req.headers, + (usageData) => { + // 回调函数:当检测到完整usage数据时记录真实token使用量 + logger.info( + '🎯 Usage callback triggered with complete data:', + JSON.stringify(usageData, null, 2) + ) + + if ( + usageData && + usageData.input_tokens !== undefined && + usageData.output_tokens !== undefined + ) { + const inputTokens = usageData.input_tokens || 0 + const outputTokens = usageData.output_tokens || 0 + const cacheCreateTokens = usageData.cache_creation_input_tokens || 0 + const cacheReadTokens = usageData.cache_read_input_tokens || 0 + const model = usageData.model || 'unknown' + + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const { accountId: usageAccountId } = usageData + apiKeyService + .recordUsage( + req.apiKey.id, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model, + usageAccountId + ) + .catch((error) => { + logger.error('❌ Failed to record stream usage:', error) + }) + + // 更新时间窗口内的token计数 + if (req.rateLimitInfo) { + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + redis + .getClient() + .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) + .catch((error) => { + logger.error('❌ Failed to update rate limit token count:', error) + }) + logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) + } + + usageDataCaptured = true + logger.api( + `📊 Stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` + ) + } else { + logger.warn( + '⚠️ Usage callback triggered but data is incomplete:', + JSON.stringify(usageData) + ) + } } - - usageDataCaptured = true; - logger.api(`📊 Stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens`); - } else { - logger.warn('⚠️ Usage callback triggered but data is incomplete:', JSON.stringify(usageData)); - } - }); + ) } else if (accountType === 'claude-console') { // Claude Console账号使用Console转发服务(需要传递accountId) - await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(req.body, req.apiKey, res, req.headers, (usageData) => { - // 回调函数:当检测到完整usage数据时记录真实token使用量 - logger.info('🎯 Usage callback triggered with complete data:', JSON.stringify(usageData, null, 2)); - - if (usageData && usageData.input_tokens !== undefined && usageData.output_tokens !== undefined) { - const inputTokens = usageData.input_tokens || 0; - const outputTokens = usageData.output_tokens || 0; - const cacheCreateTokens = usageData.cache_creation_input_tokens || 0; - const cacheReadTokens = usageData.cache_read_input_tokens || 0; - const model = usageData.model || 'unknown'; - - // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) - const usageAccountId = usageData.accountId; - apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, usageAccountId).catch(error => { - logger.error('❌ Failed to record stream usage:', error); - }); - - // 更新时间窗口内的token计数 - if (req.rateLimitInfo) { - const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; - redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens).catch(error => { - logger.error('❌ Failed to update rate limit token count:', error); - }); - logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`); + await claudeConsoleRelayService.relayStreamRequestWithUsageCapture( + req.body, + req.apiKey, + res, + req.headers, + (usageData) => { + // 回调函数:当检测到完整usage数据时记录真实token使用量 + logger.info( + '🎯 Usage callback triggered with complete data:', + JSON.stringify(usageData, null, 2) + ) + + if ( + usageData && + usageData.input_tokens !== undefined && + usageData.output_tokens !== undefined + ) { + const inputTokens = usageData.input_tokens || 0 + const outputTokens = usageData.output_tokens || 0 + const cacheCreateTokens = usageData.cache_creation_input_tokens || 0 + const cacheReadTokens = usageData.cache_read_input_tokens || 0 + const model = usageData.model || 'unknown' + + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const usageAccountId = usageData.accountId + apiKeyService + .recordUsage( + req.apiKey.id, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model, + usageAccountId + ) + .catch((error) => { + logger.error('❌ Failed to record stream usage:', error) + }) + + // 更新时间窗口内的token计数 + if (req.rateLimitInfo) { + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + redis + .getClient() + .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) + .catch((error) => { + logger.error('❌ Failed to update rate limit token count:', error) + }) + logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) + } + + usageDataCaptured = true + logger.api( + `📊 Stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` + ) + } else { + logger.warn( + '⚠️ Usage callback triggered but data is incomplete:', + JSON.stringify(usageData) + ) } - - usageDataCaptured = true; - logger.api(`📊 Stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens`); - } else { - logger.warn('⚠️ Usage callback triggered but data is incomplete:', JSON.stringify(usageData)); - } - }, accountId); + }, + accountId + ) } else if (accountType === 'bedrock') { // Bedrock账号使用Bedrock转发服务 try { - const bedrockAccountResult = await bedrockAccountService.getAccount(accountId); + const bedrockAccountResult = await bedrockAccountService.getAccount(accountId) if (!bedrockAccountResult.success) { - throw new Error('Failed to get Bedrock account details'); + throw new Error('Failed to get Bedrock account details') } - const result = await bedrockRelayService.handleStreamRequest(req.body, bedrockAccountResult.data, res); - + const result = await bedrockRelayService.handleStreamRequest( + req.body, + bedrockAccountResult.data, + res + ) + // 记录Bedrock使用统计 if (result.usage) { - const inputTokens = result.usage.input_tokens || 0; - const outputTokens = result.usage.output_tokens || 0; - - apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, 0, 0, result.model, accountId).catch(error => { - logger.error('❌ Failed to record Bedrock stream usage:', error); - }); - + const inputTokens = result.usage.input_tokens || 0 + const outputTokens = result.usage.output_tokens || 0 + + apiKeyService + .recordUsage(req.apiKey.id, inputTokens, outputTokens, 0, 0, result.model, accountId) + .catch((error) => { + logger.error('❌ Failed to record Bedrock stream usage:', error) + }) + // 更新时间窗口内的token计数 if (req.rateLimitInfo) { - const totalTokens = inputTokens + outputTokens; - redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens).catch(error => { - logger.error('❌ Failed to update rate limit token count:', error); - }); - logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`); + const totalTokens = inputTokens + outputTokens + redis + .getClient() + .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) + .catch((error) => { + logger.error('❌ Failed to update rate limit token count:', error) + }) + logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) } - - usageDataCaptured = true; - logger.api(`📊 Bedrock stream usage recorded - Model: ${result.model}, Input: ${inputTokens}, Output: ${outputTokens}, Total: ${inputTokens + outputTokens} tokens`); + + usageDataCaptured = true + logger.api( + `📊 Bedrock stream usage recorded - Model: ${result.model}, Input: ${inputTokens}, Output: ${outputTokens}, Total: ${inputTokens + outputTokens} tokens` + ) } } catch (error) { - logger.error('❌ Bedrock stream request failed:', error); + logger.error('❌ Bedrock stream request failed:', error) if (!res.headersSent) { - res.status(500).json({ error: 'Bedrock service error', message: error.message }); + return res.status(500).json({ error: 'Bedrock service error', message: error.message }) } - return; + return undefined } } - + // 流式请求完成后 - 如果没有捕获到usage数据,记录警告但不进行估算 setTimeout(() => { if (!usageDataCaptured) { - logger.warn('⚠️ No usage data captured from SSE stream - no statistics recorded (official data only)'); + logger.warn( + '⚠️ No usage data captured from SSE stream - no statistics recorded (official data only)' + ) } - }, 1000); // 1秒后检查 + }, 1000) // 1秒后检查 } else { // 非流式响应 - 只使用官方真实usage数据 logger.info('📄 Starting non-streaming request', { apiKeyId: req.apiKey.id, apiKeyName: req.apiKey.name - }); - + }) + // 生成会话哈希用于sticky会话 - const sessionHash = sessionHelper.generateSessionHash(req.body); - + const sessionHash = sessionHelper.generateSessionHash(req.body) + // 使用统一调度选择账号(传递请求的模型) - const requestedModel = req.body.model; - const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(req.apiKey, sessionHash, requestedModel); - + const requestedModel = req.body.model + const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + // 根据账号类型选择对应的转发服务 - let response; - logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`); - logger.debug(`[DEBUG] Request URL: ${req.url}`); - logger.debug(`[DEBUG] Request path: ${req.path}`); - + let response + logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`) + logger.debug(`[DEBUG] Request URL: ${req.url}`) + logger.debug(`[DEBUG] Request path: ${req.path}`) + if (accountType === 'claude-official') { // 官方Claude账号使用原有的转发服务 - response = await claudeRelayService.relayRequest(req.body, req.apiKey, req, res, req.headers); + response = await claudeRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers + ) } else if (accountType === 'claude-console') { // Claude Console账号使用Console转发服务 - logger.debug(`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}`); - response = await claudeConsoleRelayService.relayRequest(req.body, req.apiKey, req, res, req.headers, accountId); + logger.debug( + `[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}` + ) + response = await claudeConsoleRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + accountId + ) } else if (accountType === 'bedrock') { // Bedrock账号使用Bedrock转发服务 try { - const bedrockAccountResult = await bedrockAccountService.getAccount(accountId); + const bedrockAccountResult = await bedrockAccountService.getAccount(accountId) if (!bedrockAccountResult.success) { - throw new Error('Failed to get Bedrock account details'); + throw new Error('Failed to get Bedrock account details') } - const result = await bedrockRelayService.handleNonStreamRequest(req.body, bedrockAccountResult.data, req.headers); - + const result = await bedrockRelayService.handleNonStreamRequest( + req.body, + bedrockAccountResult.data, + req.headers + ) + // 构建标准响应格式 response = { statusCode: result.success ? 200 : 500, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(result.success ? result.data : { error: result.error }), - accountId: accountId - }; - + accountId + } + // 如果成功,添加使用统计到响应数据中 if (result.success && result.usage) { - const responseData = JSON.parse(response.body); - responseData.usage = result.usage; - response.body = JSON.stringify(responseData); + const responseData = JSON.parse(response.body) + responseData.usage = result.usage + response.body = JSON.stringify(responseData) } } catch (error) { - logger.error('❌ Bedrock non-stream request failed:', error); + logger.error('❌ Bedrock non-stream request failed:', error) response = { statusCode: 500, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'Bedrock service error', message: error.message }), - accountId: accountId - }; + accountId + } } } - + logger.info('📡 Claude API response received', { statusCode: response.statusCode, headers: JSON.stringify(response.headers), bodyLength: response.body ? response.body.length : 0 - }); - - res.status(response.statusCode); - + }) + + res.status(response.statusCode) + // 设置响应头,避免 Content-Length 和 Transfer-Encoding 冲突 - const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length']; - Object.keys(response.headers).forEach(key => { + const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length'] + Object.keys(response.headers).forEach((key) => { if (!skipHeaders.includes(key.toLowerCase())) { - res.setHeader(key, response.headers[key]); + res.setHeader(key, response.headers[key]) } - }); - - let usageRecorded = false; - + }) + + let usageRecorded = false + // 尝试解析JSON响应并提取usage信息 try { - const jsonData = JSON.parse(response.body); - - logger.info('📊 Parsed Claude API response:', JSON.stringify(jsonData, null, 2)); - + const jsonData = JSON.parse(response.body) + + logger.info('📊 Parsed Claude API response:', JSON.stringify(jsonData, null, 2)) + // 从Claude API响应中提取usage信息(完整的token分类体系) - if (jsonData.usage && jsonData.usage.input_tokens !== undefined && jsonData.usage.output_tokens !== undefined) { - const inputTokens = jsonData.usage.input_tokens || 0; - const outputTokens = jsonData.usage.output_tokens || 0; - const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0; - const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0; - const model = jsonData.model || req.body.model || 'unknown'; - + if ( + jsonData.usage && + jsonData.usage.input_tokens !== undefined && + jsonData.usage.output_tokens !== undefined + ) { + const inputTokens = jsonData.usage.input_tokens || 0 + const outputTokens = jsonData.usage.output_tokens || 0 + const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0 + const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0 + const model = jsonData.model || req.body.model || 'unknown' + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) - const accountId = response.accountId; - await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, accountId); - + const { accountId: responseAccountId } = response + await apiKeyService.recordUsage( + req.apiKey.id, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model, + responseAccountId + ) + // 更新时间窗口内的token计数 if (req.rateLimitInfo) { - const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; - await redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens); - logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`); + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + await redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens) + logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) } - - usageRecorded = true; - logger.api(`📊 Non-stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens`); + + usageRecorded = true + logger.api( + `📊 Non-stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` + ) } else { - logger.warn('⚠️ No usage data found in Claude API JSON response'); + logger.warn('⚠️ No usage data found in Claude API JSON response') } - - res.json(jsonData); + + res.json(jsonData) } catch (parseError) { - logger.warn('⚠️ Failed to parse Claude API response as JSON:', parseError.message); - logger.info('📄 Raw response body:', response.body); - res.send(response.body); + logger.warn('⚠️ Failed to parse Claude API response as JSON:', parseError.message) + logger.info('📄 Raw response body:', response.body) + res.send(response.body) } - + // 如果没有记录usage,只记录警告,不进行估算 if (!usageRecorded) { - logger.warn('⚠️ No usage data recorded for non-stream request - no statistics recorded (official data only)'); + logger.warn( + '⚠️ No usage data recorded for non-stream request - no statistics recorded (official data only)' + ) } } - - const duration = Date.now() - startTime; - logger.api(`✅ Request completed in ${duration}ms for key: ${req.apiKey.name}`); - + + const duration = Date.now() - startTime + logger.api(`✅ Request completed in ${duration}ms for key: ${req.apiKey.name}`) + return undefined } catch (error) { logger.error('❌ Claude relay error:', error.message, { code: error.code, stack: error.stack - }); - + }) + // 确保在任何情况下都能返回有效的JSON响应 if (!res.headersSent) { // 根据错误类型设置适当的状态码 - let statusCode = 500; - let errorType = 'Relay service error'; - + let statusCode = 500 + let errorType = 'Relay service error' + if (error.message.includes('Connection reset') || error.message.includes('socket hang up')) { - statusCode = 502; - errorType = 'Upstream connection error'; + statusCode = 502 + errorType = 'Upstream connection error' } else if (error.message.includes('Connection refused')) { - statusCode = 502; - errorType = 'Upstream service unavailable'; + statusCode = 502 + errorType = 'Upstream service unavailable' } else if (error.message.includes('timeout')) { - statusCode = 504; - errorType = 'Upstream timeout'; + statusCode = 504 + errorType = 'Upstream timeout' } else if (error.message.includes('resolve') || error.message.includes('ENOTFOUND')) { - statusCode = 502; - errorType = 'Upstream hostname resolution failed'; + statusCode = 502 + errorType = 'Upstream hostname resolution failed' } - - res.status(statusCode).json({ + + return res.status(statusCode).json({ error: errorType, message: error.message || 'An unexpected error occurred', timestamp: new Date().toISOString() - }); + }) } else { // 如果响应头已经发送,尝试结束响应 if (!res.destroyed && !res.finished) { - res.end(); + res.end() } + return undefined } } } // 🚀 Claude API messages 端点 - /api/v1/messages -router.post('/v1/messages', authenticateApiKey, handleMessagesRequest); +router.post('/v1/messages', authenticateApiKey, handleMessagesRequest) // 🚀 Claude API messages 端点 - /claude/v1/messages (别名) -router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest); +router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest) // 📋 模型列表端点 - Claude Code 客户端需要 router.get('/v1/models', authenticateApiKey, async (req, res) => { @@ -368,66 +490,65 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { owned_by: 'anthropic' }, { - id: 'claude-3-5-haiku-20241022', + id: 'claude-3-5-haiku-20241022', object: 'model', created: 1669599635, owned_by: 'anthropic' }, { id: 'claude-3-opus-20240229', - object: 'model', + object: 'model', created: 1669599635, owned_by: 'anthropic' }, { id: 'claude-sonnet-4-20250514', object: 'model', - created: 1669599635, + created: 1669599635, owned_by: 'anthropic' } - ]; - + ] + res.json({ object: 'list', data: models - }); - + }) } catch (error) { - logger.error('❌ Models list error:', error); + logger.error('❌ Models list error:', error) res.status(500).json({ error: 'Failed to get models list', message: error.message - }); + }) } -}); +}) // 🏥 健康检查端点 router.get('/health', async (req, res) => { try { - const healthStatus = await claudeRelayService.healthCheck(); - + const healthStatus = await claudeRelayService.healthCheck() + res.status(healthStatus.healthy ? 200 : 503).json({ status: healthStatus.healthy ? 'healthy' : 'unhealthy', service: 'claude-relay-service', version: '1.0.0', ...healthStatus - }); + }) } catch (error) { - logger.error('❌ Health check error:', error); + logger.error('❌ Health check error:', error) res.status(503).json({ status: 'unhealthy', service: 'claude-relay-service', error: error.message, timestamp: new Date().toISOString() - }); + }) } -}); +}) // 📊 API Key状态检查端点 - /api/v1/key-info router.get('/v1/key-info', authenticateApiKey, async (req, res) => { try { - const usage = await apiKeyService.getUsageStats(req.apiKey.id); - + const usage = await apiKeyService.getUsageStats(req.apiKey.id) + res.json({ keyInfo: { id: req.apiKey.id, @@ -436,21 +557,21 @@ router.get('/v1/key-info', authenticateApiKey, async (req, res) => { usage }, timestamp: new Date().toISOString() - }); + }) } catch (error) { - logger.error('❌ Key info error:', error); + logger.error('❌ Key info error:', error) res.status(500).json({ error: 'Failed to get key info', message: error.message - }); + }) } -}); +}) // 📈 使用统计端点 - /api/v1/usage router.get('/v1/usage', authenticateApiKey, async (req, res) => { try { - const usage = await apiKeyService.getUsageStats(req.apiKey.id); - + const usage = await apiKeyService.getUsageStats(req.apiKey.id) + res.json({ usage, limits: { @@ -458,56 +579,56 @@ router.get('/v1/usage', authenticateApiKey, async (req, res) => { requests: 0 // 请求限制已移除 }, timestamp: new Date().toISOString() - }); + }) } catch (error) { - logger.error('❌ Usage stats error:', error); + logger.error('❌ Usage stats error:', error) res.status(500).json({ error: 'Failed to get usage stats', message: error.message - }); + }) } -}); +}) // 👤 用户信息端点 - Claude Code 客户端需要 router.get('/v1/me', authenticateApiKey, async (req, res) => { try { // 返回基础用户信息 res.json({ - id: 'user_' + req.apiKey.id, - type: 'user', + id: `user_${req.apiKey.id}`, + type: 'user', display_name: req.apiKey.name || 'API User', created_at: new Date().toISOString() - }); + }) } catch (error) { - logger.error('❌ User info error:', error); + logger.error('❌ User info error:', error) res.status(500).json({ error: 'Failed to get user info', message: error.message - }); + }) } -}); +}) // 💰 余额/限制端点 - Claude Code 客户端需要 router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, res) => { try { - const usage = await apiKeyService.getUsageStats(req.apiKey.id); - + const usage = await apiKeyService.getUsageStats(req.apiKey.id) + res.json({ object: 'usage', data: [ { - type: 'credit_balance', + type: 'credit_balance', credit_balance: req.apiKey.tokenLimit - (usage.totalTokens || 0) } ] - }); + }) } catch (error) { - logger.error('❌ Organization usage error:', error); + logger.error('❌ Organization usage error:', error) res.status(500).json({ error: 'Failed to get usage info', message: error.message - }); + }) } -}); +}) -module.exports = router; \ No newline at end of file +module.exports = router diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js index 1241aad6..31e47c7d 100644 --- a/src/routes/apiStats.js +++ b/src/routes/apiStats.js @@ -1,26 +1,26 @@ -const express = require('express'); -const redis = require('../models/redis'); -const logger = require('../utils/logger'); -const apiKeyService = require('../services/apiKeyService'); -const CostCalculator = require('../utils/costCalculator'); +const express = require('express') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const apiKeyService = require('../services/apiKeyService') +const CostCalculator = require('../utils/costCalculator') -const router = express.Router(); +const router = express.Router() // 🏠 重定向页面请求到新版 admin-spa router.get('/', (req, res) => { - res.redirect(301, '/admin-next/api-stats'); -}); + res.redirect(301, '/admin-next/api-stats') +}) // 🔑 获取 API Key 对应的 ID router.post('/api/get-key-id', async (req, res) => { try { - const { apiKey } = req.body; - + const { apiKey } = req.body + if (!apiKey) { return res.status(400).json({ error: 'API Key is required', message: 'Please provide your API Key' - }); + }) } // 基本API Key格式验证 @@ -28,108 +28,110 @@ router.post('/api/get-key-id', async (req, res) => { return res.status(400).json({ error: 'Invalid API key format', message: 'API key format is invalid' - }); + }) } // 验证API Key - const validation = await apiKeyService.validateApiKey(apiKey); - + const validation = await apiKeyService.validateApiKey(apiKey) + if (!validation.valid) { - const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; - logger.security(`🔒 Invalid API key in get-key-id: ${validation.error} from ${clientIP}`); + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.security(`🔒 Invalid API key in get-key-id: ${validation.error} from ${clientIP}`) return res.status(401).json({ error: 'Invalid API key', message: validation.error - }); + }) } - const keyData = validation.keyData; - - res.json({ + const { keyData } = validation + + return res.json({ success: true, data: { id: keyData.id } - }); - + }) } catch (error) { - logger.error('❌ Failed to get API key ID:', error); - res.status(500).json({ + logger.error('❌ Failed to get API key ID:', error) + return res.status(500).json({ error: 'Internal server error', message: 'Failed to retrieve API key ID' - }); + }) } -}); +}) // 📊 用户API Key统计查询接口 - 安全的自查询接口 router.post('/api/user-stats', async (req, res) => { try { - const { apiKey, apiId } = req.body; - - let keyData; - let keyId; - + const { apiKey, apiId } = req.body + + let keyData + let keyId + if (apiId) { // 通过 apiId 查询 - if (typeof apiId !== 'string' || !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) { + if ( + typeof apiId !== 'string' || + !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i) + ) { return res.status(400).json({ error: 'Invalid API ID format', message: 'API ID must be a valid UUID' - }); + }) } - + // 直接通过 ID 获取 API Key 数据 - keyData = await redis.getApiKey(apiId); - + keyData = await redis.getApiKey(apiId) + if (!keyData || Object.keys(keyData).length === 0) { - logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`); + logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`) return res.status(404).json({ error: 'API key not found', message: 'The specified API key does not exist' - }); + }) } - + // 检查是否激活 if (keyData.isActive !== 'true') { return res.status(403).json({ error: 'API key is disabled', message: 'This API key has been disabled' - }); + }) } - + // 检查是否过期 if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { return res.status(403).json({ error: 'API key has expired', message: 'This API key has expired' - }); + }) } - - keyId = apiId; - + + keyId = apiId + // 获取使用统计 - const usage = await redis.getUsageStats(keyId); - + const usage = await redis.getUsageStats(keyId) + // 获取当日费用统计 - const dailyCost = await redis.getDailyCost(keyId); - + const dailyCost = await redis.getDailyCost(keyId) + // 处理数据格式,与 validateApiKey 返回的格式保持一致 // 解析限制模型数据 - let restrictedModels = []; + let restrictedModels = [] try { - restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : []; + restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : [] } catch (e) { - restrictedModels = []; + restrictedModels = [] } - + // 解析允许的客户端数据 - let allowedClients = []; + let allowedClients = [] try { - allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : []; + allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [] } catch (e) { - allowedClients = []; + allowedClients = [] } - + // 格式化 keyData keyData = { ...keyData, @@ -140,70 +142,75 @@ router.post('/api/user-stats', async (req, res) => { dailyCostLimit: parseFloat(keyData.dailyCostLimit) || 0, dailyCost: dailyCost || 0, enableModelRestriction: keyData.enableModelRestriction === 'true', - restrictedModels: restrictedModels, + restrictedModels, enableClientRestriction: keyData.enableClientRestriction === 'true', - allowedClients: allowedClients, + allowedClients, permissions: keyData.permissions || 'all', - usage: usage // 使用完整的 usage 数据,而不是只有 total - }; - + usage // 使用完整的 usage 数据,而不是只有 total + } } else if (apiKey) { // 通过 apiKey 查询(保持向后兼容) if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { - logger.security(`🔒 Invalid API key format in user stats query from ${req.ip || 'unknown'}`); + logger.security(`🔒 Invalid API key format in user stats query from ${req.ip || 'unknown'}`) return res.status(400).json({ error: 'Invalid API key format', message: 'API key format is invalid' - }); + }) } // 验证API Key(重用现有的验证逻辑) - const validation = await apiKeyService.validateApiKey(apiKey); - + const validation = await apiKeyService.validateApiKey(apiKey) + if (!validation.valid) { - const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; - logger.security(`🔒 Invalid API key in user stats query: ${validation.error} from ${clientIP}`); + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.security( + `🔒 Invalid API key in user stats query: ${validation.error} from ${clientIP}` + ) return res.status(401).json({ error: 'Invalid API key', message: validation.error - }); + }) } - keyData = validation.keyData; - keyId = keyData.id; - + const { keyData: validatedKeyData } = validation + keyData = validatedKeyData + keyId = keyData.id } else { - logger.security(`🔒 Missing API key or ID in user stats query from ${req.ip || 'unknown'}`); + logger.security(`🔒 Missing API key or ID in user stats query from ${req.ip || 'unknown'}`) return res.status(400).json({ error: 'API Key or ID is required', message: 'Please provide your API Key or API ID' - }); + }) } // 记录合法查询 - logger.api(`📊 User stats query from key: ${keyData.name} (${keyId}) from ${req.ip || 'unknown'}`); + logger.api( + `📊 User stats query from key: ${keyData.name} (${keyId}) from ${req.ip || 'unknown'}` + ) // 获取验证结果中的完整keyData(包含isActive状态和cost信息) - const fullKeyData = keyData; - + const fullKeyData = keyData + // 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算) - let totalCost = 0; - let formattedCost = '$0.000000'; - + let totalCost = 0 + let formattedCost = '$0.000000' + try { - const client = redis.getClientSafe(); - + const client = redis.getClientSafe() + // 获取所有月度模型统计(与model-stats接口相同的逻辑) - const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`); - const modelUsageMap = new Map(); - + const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`) + const modelUsageMap = new Map() + for (const key of allModelKeys) { - const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/); - if (!modelMatch) continue; - - const model = modelMatch[1]; - const data = await client.hgetall(key); - + 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, { @@ -211,17 +218,17 @@ router.post('/api/user-stats', async (req, res) => { 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; + + const modelUsage = modelUsageMap.get(model) + modelUsage.inputTokens += parseInt(data.inputTokens) || 0 + modelUsage.outputTokens += parseInt(data.outputTokens) || 0 + modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 } } - + // 按模型计算费用并汇总 for (const [model, usage] of modelUsageMap) { const usageData = { @@ -229,66 +236,65 @@ router.post('/api/user-stats', async (req, res) => { output_tokens: usage.outputTokens, cache_creation_input_tokens: usage.cacheCreateTokens, cache_read_input_tokens: usage.cacheReadTokens - }; - - const costResult = CostCalculator.calculateCost(usageData, model); - totalCost += costResult.costs.total; + } + + const costResult = CostCalculator.calculateCost(usageData, model) + totalCost += costResult.costs.total } - + // 如果没有模型级别的详细数据,回退到总体数据计算 if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) { - const usage = fullKeyData.usage.total; + const usage = fullKeyData.usage.total const costUsage = { input_tokens: usage.inputTokens || 0, output_tokens: usage.outputTokens || 0, cache_creation_input_tokens: usage.cacheCreateTokens || 0, cache_read_input_tokens: usage.cacheReadTokens || 0 - }; - - const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022'); - totalCost = costResult.costs.total; + } + + const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022') + totalCost = costResult.costs.total } - - formattedCost = CostCalculator.formatCost(totalCost); - + + formattedCost = CostCalculator.formatCost(totalCost) } catch (error) { - logger.warn(`Failed to calculate detailed cost for key ${keyId}:`, error); + logger.warn(`Failed to calculate detailed cost for key ${keyId}:`, error) // 回退到简单计算 if (fullKeyData.usage?.total?.allTokens > 0) { - const usage = fullKeyData.usage.total; + const usage = fullKeyData.usage.total const costUsage = { input_tokens: usage.inputTokens || 0, output_tokens: usage.outputTokens || 0, cache_creation_input_tokens: usage.cacheCreateTokens || 0, cache_read_input_tokens: usage.cacheReadTokens || 0 - }; - - const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022'); - totalCost = costResult.costs.total; - formattedCost = costResult.formatted.total; + } + + const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022') + totalCost = costResult.costs.total + formattedCost = costResult.formatted.total } } // 获取当前使用量 - let currentWindowRequests = 0; - let currentWindowTokens = 0; - let currentDailyCost = 0; - + let currentWindowRequests = 0 + let currentWindowTokens = 0 + let currentDailyCost = 0 + try { // 获取当前时间窗口的请求次数和Token使用量 if (fullKeyData.rateLimitWindow > 0) { - const client = redis.getClientSafe(); - const requestCountKey = `rate_limit:requests:${keyId}`; - const tokenCountKey = `rate_limit:tokens:${keyId}`; - - currentWindowRequests = parseInt(await client.get(requestCountKey) || '0'); - currentWindowTokens = parseInt(await client.get(tokenCountKey) || '0'); + const client = redis.getClientSafe() + const requestCountKey = `rate_limit:requests:${keyId}` + const tokenCountKey = `rate_limit:tokens:${keyId}` + + currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') + currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') } - + // 获取当日费用 - currentDailyCost = await redis.getDailyCost(keyId) || 0; + currentDailyCost = (await redis.getDailyCost(keyId)) || 0 } catch (error) { - logger.warn(`Failed to get current usage for key ${keyId}:`, error); + logger.warn(`Failed to get current usage for key ${keyId}:`, error) } // 构建响应数据(只返回该API Key自己的信息,确保不泄露其他信息) @@ -300,7 +306,7 @@ router.post('/api/user-stats', async (req, res) => { createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, permissions: fullKeyData.permissions, - + // 使用统计(使用验证结果中的完整数据) usage: { total: { @@ -314,10 +320,10 @@ router.post('/api/user-stats', async (req, res) => { cacheReadTokens: 0 }), cost: totalCost, - formattedCost: formattedCost + formattedCost } }, - + // 限制信息(显示配置和当前使用量) limits: { tokenLimit: fullKeyData.tokenLimit || 0, @@ -326,17 +332,23 @@ router.post('/api/user-stats', async (req, res) => { rateLimitRequests: fullKeyData.rateLimitRequests || 0, dailyCostLimit: fullKeyData.dailyCostLimit || 0, // 当前使用量 - currentWindowRequests: currentWindowRequests, - currentWindowTokens: currentWindowTokens, - currentDailyCost: currentDailyCost + currentWindowRequests, + currentWindowTokens, + currentDailyCost }, - + // 绑定的账户信息(只显示ID,不显示敏感信息) accounts: { - claudeAccountId: fullKeyData.claudeAccountId && fullKeyData.claudeAccountId !== '' ? fullKeyData.claudeAccountId : null, - geminiAccountId: fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== '' ? fullKeyData.geminiAccountId : null + claudeAccountId: + fullKeyData.claudeAccountId && fullKeyData.claudeAccountId !== '' + ? fullKeyData.claudeAccountId + : null, + geminiAccountId: + fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== '' + ? fullKeyData.geminiAccountId + : null }, - + // 模型和客户端限制信息 restrictions: { enableModelRestriction: fullKeyData.enableModelRestriction || false, @@ -344,126 +356,137 @@ router.post('/api/user-stats', async (req, res) => { enableClientRestriction: fullKeyData.enableClientRestriction || false, allowedClients: fullKeyData.allowedClients || [] } - }; + } - res.json({ + return res.json({ success: true, data: responseData - }); - + }) } catch (error) { - logger.error('❌ Failed to process user stats query:', error); - res.status(500).json({ + logger.error('❌ Failed to process user stats query:', error) + return res.status(500).json({ error: 'Internal server error', message: 'Failed to retrieve API key statistics' - }); + }) } -}); +}) // 📊 用户模型统计查询接口 - 安全的自查询接口 router.post('/api/user-model-stats', async (req, res) => { try { - const { apiKey, apiId, period = 'monthly' } = req.body; - - let keyData; - let keyId; - + const { apiKey, apiId, period = 'monthly' } = req.body + + let keyData + let keyId + if (apiId) { // 通过 apiId 查询 - if (typeof apiId !== 'string' || !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) { + if ( + typeof apiId !== 'string' || + !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i) + ) { return res.status(400).json({ error: 'Invalid API ID format', message: 'API ID must be a valid UUID' - }); + }) } - + // 直接通过 ID 获取 API Key 数据 - keyData = await redis.getApiKey(apiId); - + keyData = await redis.getApiKey(apiId) + if (!keyData || Object.keys(keyData).length === 0) { - logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`); + logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`) return res.status(404).json({ error: 'API key not found', message: 'The specified API key does not exist' - }); + }) } - + // 检查是否激活 if (keyData.isActive !== 'true') { return res.status(403).json({ error: 'API key is disabled', message: 'This API key has been disabled' - }); + }) } - - keyId = apiId; - + + keyId = apiId + // 获取使用统计 - const usage = await redis.getUsageStats(keyId); - keyData.usage = { total: usage.total }; - + const usage = await redis.getUsageStats(keyId) + keyData.usage = { total: usage.total } } else if (apiKey) { // 通过 apiKey 查询(保持向后兼容) // 验证API Key - const validation = await apiKeyService.validateApiKey(apiKey); - + const validation = await apiKeyService.validateApiKey(apiKey) + if (!validation.valid) { - const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; - logger.security(`🔒 Invalid API key in user model stats query: ${validation.error} from ${clientIP}`); + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.security( + `🔒 Invalid API key in user model stats query: ${validation.error} from ${clientIP}` + ) return res.status(401).json({ error: 'Invalid API key', message: validation.error - }); + }) } - keyData = validation.keyData; - keyId = keyData.id; - + const { keyData: validatedKeyData } = validation + keyData = validatedKeyData + keyId = keyData.id } else { - logger.security(`🔒 Missing API key or ID in user model stats query from ${req.ip || 'unknown'}`); + logger.security( + `🔒 Missing API key or ID in user model stats query from ${req.ip || 'unknown'}` + ) return res.status(400).json({ error: 'API Key or ID is required', message: 'Please provide your API Key or API ID' - }); + }) } - - logger.api(`📊 User model stats query from key: ${keyData.name} (${keyId}) for period: ${period}`); + + logger.api( + `📊 User model stats query from key: ${keyData.name} (${keyId}) for period: ${period}` + ) // 重用管理后台的模型统计逻辑,但只返回该API Key的数据 - const client = redis.getClientSafe(); + const client = redis.getClientSafe() // 使用与管理页面相同的时区处理逻辑 - const tzDate = redis.getDateInTimezone(); - const today = redis.getDateStringInTimezone(); - const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; - - const pattern = period === 'daily' ? - `usage:${keyId}:model:daily:*:${today}` : - `usage:${keyId}:model:monthly:*:${currentMonth}`; - - const keys = await client.keys(pattern); - const modelStats = []; - + const tzDate = redis.getDateInTimezone() + const today = redis.getDateStringInTimezone() + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}` + + const pattern = + period === 'daily' + ? `usage:${keyId}:model:daily:*:${today}` + : `usage:${keyId}:model:monthly:*:${currentMonth}` + + const keys = await client.keys(pattern) + const modelStats = [] + for (const key of keys) { - const match = key.match(period === 'daily' ? - /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ : - /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ - ); - - if (!match) continue; - - const model = match[1]; - const data = await client.hgetall(key); - + const match = key.match( + period === 'daily' + ? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ + : /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ + ) + + if (!match) { + continue + } + + const model = match[1] + const data = await client.hgetall(key) + if (data && Object.keys(data).length > 0) { const usage = { input_tokens: parseInt(data.inputTokens) || 0, output_tokens: parseInt(data.outputTokens) || 0, cache_creation_input_tokens: parseInt(data.cacheCreateTokens) || 0, cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0 - }; - - const costData = CostCalculator.calculateCost(usage, model); - + } + + const costData = CostCalculator.calculateCost(usage, model) + modelStats.push({ model, requests: parseInt(data.requests) || 0, @@ -475,32 +498,31 @@ router.post('/api/user-model-stats', async (req, res) => { costs: costData.costs, formatted: costData.formatted, pricing: costData.pricing - }); + }) } } // 如果没有详细的模型数据,不显示历史数据以避免混淆 // 只有在查询特定时间段时返回空数组,表示该时间段确实没有数据 if (modelStats.length === 0) { - logger.info(`📊 No model stats found for key ${keyId} in period ${period}`); + logger.info(`📊 No model stats found for key ${keyId} in period ${period}`) } // 按总token数降序排列 - modelStats.sort((a, b) => b.allTokens - a.allTokens); + modelStats.sort((a, b) => b.allTokens - a.allTokens) - res.json({ + return res.json({ success: true, data: modelStats, - period: period - }); - + period + }) } catch (error) { - logger.error('❌ Failed to process user model stats query:', error); - res.status(500).json({ + logger.error('❌ Failed to process user model stats query:', error) + return res.status(500).json({ error: 'Internal server error', message: 'Failed to retrieve model statistics' - }); + }) } -}); +}) -module.exports = router; \ No newline at end of file +module.exports = router diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index 0b1c737b..20cdca13 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -1,13 +1,13 @@ -const express = require('express'); -const router = express.Router(); -const logger = require('../utils/logger'); -const { authenticateApiKey } = require('../middleware/auth'); -const geminiAccountService = require('../services/geminiAccountService'); -const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService'); -const crypto = require('crypto'); -const sessionHelper = require('../utils/sessionHelper'); -const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler'); -const apiKeyService = require('../services/apiKeyService'); +const express = require('express') +const router = express.Router() +const logger = require('../utils/logger') +const { authenticateApiKey } = require('../middleware/auth') +const geminiAccountService = require('../services/geminiAccountService') +const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService') +const crypto = require('crypto') +const sessionHelper = require('../utils/sessionHelper') +const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') +const apiKeyService = require('../services/apiKeyService') // const { OAuth2Client } = require('google-auth-library'); // OAuth2Client is not used in this file // 生成会话哈希 @@ -16,24 +16,26 @@ function generateSessionHash(req) { req.headers['user-agent'], req.ip, req.headers['x-api-key']?.substring(0, 10) - ].filter(Boolean).join(':'); + ] + .filter(Boolean) + .join(':') - return crypto.createHash('sha256').update(sessionData).digest('hex'); + return crypto.createHash('sha256').update(sessionData).digest('hex') } // 检查 API Key 权限 function checkPermissions(apiKeyData, requiredPermission = 'gemini') { - const permissions = apiKeyData.permissions || 'all'; - return permissions === 'all' || permissions === requiredPermission; + const permissions = apiKeyData.permissions || 'all' + return permissions === 'all' || permissions === requiredPermission } // Gemini 消息处理端点 router.post('/messages', authenticateApiKey, async (req, res) => { - const startTime = Date.now(); - let abortController = null; + const startTime = Date.now() + let abortController = null try { - const apiKeyData = req.apiKey; + const apiKeyData = req.apiKey // 检查权限 if (!checkPermissions(apiKeyData, 'gemini')) { @@ -42,7 +44,7 @@ router.post('/messages', authenticateApiKey, async (req, res) => { message: 'This API key does not have permission to access Gemini', type: 'permission_denied' } - }); + }) } // 提取请求参数 @@ -52,7 +54,7 @@ router.post('/messages', authenticateApiKey, async (req, res) => { temperature = 0.7, max_tokens = 4096, stream = false - } = req.body; + } = req.body // 验证必需参数 if (!messages || !Array.isArray(messages) || messages.length === 0) { @@ -61,57 +63,58 @@ router.post('/messages', authenticateApiKey, async (req, res) => { message: 'Messages array is required', type: 'invalid_request_error' } - }); + }) } // 生成会话哈希用于粘性会话 - const sessionHash = generateSessionHash(req); + const sessionHash = generateSessionHash(req) // 使用统一调度选择可用的 Gemini 账户(传递请求的模型) - let accountId; + let accountId try { const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey( apiKeyData, sessionHash, - model // 传递请求的模型进行过滤 - ); - accountId = schedulerResult.accountId; + model // 传递请求的模型进行过滤 + ) + const { accountId: selectedAccountId } = schedulerResult + accountId = selectedAccountId } catch (error) { - logger.error('Failed to select Gemini account:', error); + logger.error('Failed to select Gemini account:', error) return res.status(503).json({ error: { message: error.message || 'No available Gemini accounts', type: 'service_unavailable' } - }); + }) } // 获取账户详情 - const account = await geminiAccountService.getAccount(accountId); + const account = await geminiAccountService.getAccount(accountId) if (!account) { return res.status(503).json({ error: { message: 'Selected account not found', type: 'service_unavailable' } - }); + }) } - logger.info(`Using Gemini account: ${account.id} for API key: ${apiKeyData.id}`); + logger.info(`Using Gemini account: ${account.id} for API key: ${apiKeyData.id}`) // 标记账户被使用 - await geminiAccountService.markAccountUsed(account.id); + await geminiAccountService.markAccountUsed(account.id) // 创建中止控制器 - abortController = new AbortController(); + abortController = new AbortController() // 处理客户端断开连接 req.on('close', () => { if (abortController && !abortController.signal.aborted) { - logger.info('Client disconnected, aborting Gemini request'); - abortController.abort(); + logger.info('Client disconnected, aborting Gemini request') + abortController.abort() } - }); + }) // 发送请求到 Gemini const geminiResponse = await sendGeminiRequest({ @@ -126,64 +129,64 @@ router.post('/messages', authenticateApiKey, async (req, res) => { signal: abortController.signal, projectId: account.projectId, accountId: account.id - }); + }) if (stream) { // 设置流式响应头 - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') // 流式传输响应 for await (const chunk of geminiResponse) { if (abortController.signal.aborted) { - break; + break } - res.write(chunk); + res.write(chunk) } - res.end(); + res.end() } else { // 非流式响应 - res.json(geminiResponse); + res.json(geminiResponse) } - const duration = Date.now() - startTime; - logger.info(`Gemini request completed in ${duration}ms`); - + const duration = Date.now() - startTime + logger.info(`Gemini request completed in ${duration}ms`) } catch (error) { - logger.error('Gemini request error:', error); + logger.error('Gemini request error:', error) // 处理速率限制 if (error.status === 429) { if (req.apiKey && req.account) { - await geminiAccountService.setAccountRateLimited(req.account.id, true); + await geminiAccountService.setAccountRateLimited(req.account.id, true) } } // 返回错误响应 - const status = error.status || 500; + const status = error.status || 500 const errorResponse = { error: error.error || { message: error.message || 'Internal server error', type: 'api_error' } - }; + } - res.status(status).json(errorResponse); + res.status(status).json(errorResponse) } finally { // 清理资源 if (abortController) { - abortController = null; + abortController = null } } -}); + return undefined +}) // 获取可用模型列表 router.get('/models', authenticateApiKey, async (req, res) => { try { - const apiKeyData = req.apiKey; + const apiKeyData = req.apiKey // 检查权限 if (!checkPermissions(apiKeyData, 'gemini')) { @@ -192,16 +195,20 @@ router.get('/models', authenticateApiKey, async (req, res) => { message: 'This API key does not have permission to access Gemini', type: 'permission_denied' } - }); + }) } // 选择账户获取模型列表 - let account = null; + let account = null try { - const accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey(apiKeyData, null, null); - account = await geminiAccountService.getAccount(accountSelection.accountId); + const accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + apiKeyData, + null, + null + ) + account = await geminiAccountService.getAccount(accountSelection.accountId) } catch (error) { - logger.warn('Failed to select Gemini account for models endpoint:', error); + logger.warn('Failed to select Gemini account for models endpoint:', error) } if (!account) { @@ -216,32 +223,32 @@ router.get('/models', authenticateApiKey, async (req, res) => { owned_by: 'google' } ] - }); + }) } // 获取模型列表 - const models = await getAvailableModels(account.accessToken, account.proxy); + const models = await getAvailableModels(account.accessToken, account.proxy) res.json({ object: 'list', data: models - }); - + }) } catch (error) { - logger.error('Failed to get Gemini models:', error); + logger.error('Failed to get Gemini models:', error) res.status(500).json({ error: { message: 'Failed to retrieve models', type: 'api_error' } - }); + }) } -}); + return undefined +}) // 使用情况统计(与 Claude 共用) router.get('/usage', authenticateApiKey, async (req, res) => { try { - const usage = req.apiKey.usage; + const { usage } = req.apiKey res.json({ object: 'usage', @@ -251,22 +258,22 @@ router.get('/usage', authenticateApiKey, async (req, res) => { daily_requests: usage.daily.requests, monthly_tokens: usage.monthly.tokens, monthly_requests: usage.monthly.requests - }); + }) } catch (error) { - logger.error('Failed to get usage stats:', error); + logger.error('Failed to get usage stats:', error) res.status(500).json({ error: { message: 'Failed to retrieve usage statistics', type: 'api_error' } - }); + }) } -}); +}) // API Key 信息(与 Claude 共用) router.get('/key-info', authenticateApiKey, async (req, res) => { try { - const keyData = req.apiKey; + const keyData = req.apiKey res.json({ id: keyData.id, @@ -274,9 +281,10 @@ router.get('/key-info', authenticateApiKey, async (req, res) => { permissions: keyData.permissions || 'all', token_limit: keyData.tokenLimit, tokens_used: keyData.usage.total.tokens, - tokens_remaining: keyData.tokenLimit > 0 - ? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens) - : null, + tokens_remaining: + keyData.tokenLimit > 0 + ? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens) + : null, rate_limit: { window: keyData.rateLimitWindow, requests: keyData.rateLimitRequests @@ -286,88 +294,105 @@ router.get('/key-info', authenticateApiKey, async (req, res) => { enabled: keyData.enableModelRestriction, models: keyData.restrictedModels } - }); + }) } catch (error) { - logger.error('Failed to get key info:', error); + logger.error('Failed to get key info:', error) res.status(500).json({ error: { message: 'Failed to retrieve API key information', type: 'api_error' } - }); + }) } -}); +}) // 共用的 loadCodeAssist 处理函数 async function handleLoadCodeAssist(req, res) { try { - const sessionHash = sessionHelper.generateSessionHash(req.body); + const sessionHash = sessionHelper.generateSessionHash(req.body) // 使用统一调度选择账号(传递请求的模型) - const requestedModel = req.body.model; - const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(req.apiKey, sessionHash, requestedModel); - const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId); - logger.info(`accessToken: ${accessToken}`); + const requestedModel = req.body.model + const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId) + logger.info(`accessToken: ${accessToken}`) - const { metadata, cloudaicompanionProject } = req.body; + const { metadata, cloudaicompanionProject } = req.body - const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'; + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' logger.info(`LoadCodeAssist request (${version})`, { metadata: metadata || {}, cloudaicompanionProject: cloudaicompanionProject || null, apiKeyId: req.apiKey?.id || 'unknown' - }); + }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken); - const response = await geminiAccountService.loadCodeAssist(client, cloudaicompanionProject); - res.json(response); + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) + const response = await geminiAccountService.loadCodeAssist(client, cloudaicompanionProject) + res.json(response) } catch (error) { - const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'; - logger.error(`Error in loadCodeAssist endpoint (${version})`, { error: error.message }); + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in loadCodeAssist endpoint (${version})`, { error: error.message }) res.status(500).json({ error: 'Internal server error', message: error.message - }); + }) } } // 共用的 onboardUser 处理函数 async function handleOnboardUser(req, res) { try { - const { tierId, cloudaicompanionProject, metadata } = req.body; - const sessionHash = sessionHelper.generateSessionHash(req.body); + const { tierId, cloudaicompanionProject, metadata } = req.body + const sessionHash = sessionHelper.generateSessionHash(req.body) // 使用统一调度选择账号(传递请求的模型) - const requestedModel = req.body.model; - const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(req.apiKey, sessionHash, requestedModel); - const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId); + const requestedModel = req.body.model + const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId) - const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'; + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' logger.info(`OnboardUser request (${version})`, { tierId: tierId || 'not provided', cloudaicompanionProject: cloudaicompanionProject || null, metadata: metadata || {}, apiKeyId: req.apiKey?.id || 'unknown' - }); + }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken); + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) // 如果提供了完整参数,直接调用onboardUser if (tierId && metadata) { - const response = await geminiAccountService.onboardUser(client, tierId, cloudaicompanionProject, metadata); - res.json(response); + const response = await geminiAccountService.onboardUser( + client, + tierId, + cloudaicompanionProject, + metadata + ) + res.json(response) } else { // 否则执行完整的setupUser流程 - const response = await geminiAccountService.setupUser(client, cloudaicompanionProject, metadata); - res.json(response); + const response = await geminiAccountService.setupUser( + client, + cloudaicompanionProject, + metadata + ) + res.json(response) } } catch (error) { - const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'; - logger.error(`Error in onboardUser endpoint (${version})`, { error: error.message }); + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in onboardUser endpoint (${version})`, { error: error.message }) res.status(500).json({ error: 'Internal server error', message: error.message - }); + }) } } @@ -375,9 +400,9 @@ async function handleOnboardUser(req, res) { async function handleCountTokens(req, res) { try { // 处理请求体结构,支持直接 contents 或 request.contents - const requestData = req.body.request || req.body; - const { contents, model = 'gemini-2.0-flash-exp' } = requestData; - const sessionHash = sessionHelper.generateSessionHash(req.body); + const requestData = req.body.request || req.body + const { contents, model = 'gemini-2.0-flash-exp' } = requestData + const sessionHash = sessionHelper.generateSessionHash(req.body) // 验证必需参数 if (!contents || !Array.isArray(contents)) { @@ -386,49 +411,54 @@ async function handleCountTokens(req, res) { message: 'Contents array is required', type: 'invalid_request_error' } - }); + }) } // 使用统一调度选择账号 - const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(req.apiKey, sessionHash, model); - const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId); + const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model + ) + const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId) - const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'; + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' logger.info(`CountTokens request (${version})`, { - model: model, + model, contentsLength: contents.length, apiKeyId: req.apiKey?.id || 'unknown' - }); + }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken); - const response = await geminiAccountService.countTokens(client, contents, model); + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) + const response = await geminiAccountService.countTokens(client, contents, model) - res.json(response); + res.json(response) } catch (error) { - const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'; - logger.error(`Error in countTokens endpoint (${version})`, { error: error.message }); + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in countTokens endpoint (${version})`, { error: error.message }) res.status(500).json({ error: { message: error.message || 'Internal server error', type: 'api_error' } - }); + }) } + return undefined } // 共用的 generateContent 处理函数 async function handleGenerateContent(req, res) { try { - const { model, project, user_prompt_id, request: requestData } = req.body; - const sessionHash = sessionHelper.generateSessionHash(req.body); - + const { model, project, user_prompt_id, request: requestData } = req.body + const sessionHash = sessionHelper.generateSessionHash(req.body) + // 处理不同格式的请求 - let actualRequestData = requestData; + let actualRequestData = requestData if (!requestData) { if (req.body.messages) { // 这是 OpenAI 格式的请求,构建 Gemini 格式的 request 对象 actualRequestData = { - contents: req.body.messages.map(msg => ({ + contents: req.body.messages.map((msg) => ({ role: msg.role === 'assistant' ? 'model' : msg.role, parts: [{ text: msg.content }] })), @@ -438,10 +468,10 @@ async function handleGenerateContent(req, res) { topP: req.body.top_p !== undefined ? req.body.top_p : 0.95, topK: req.body.top_k !== undefined ? req.body.top_k : 40 } - }; + } } else if (req.body.contents) { // 直接的 Gemini 格式请求(没有 request 包装) - actualRequestData = req.body; + actualRequestData = req.body } } @@ -452,35 +482,39 @@ async function handleGenerateContent(req, res) { message: 'Request contents are required', type: 'invalid_request_error' } - }); + }) } // 使用统一调度选择账号 - const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(req.apiKey, sessionHash, model); - const account = await geminiAccountService.getAccount(accountId); - const { accessToken, refreshToken } = account; + const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model + ) + const account = await geminiAccountService.getAccount(accountId) + const { accessToken, refreshToken } = account - const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'; + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' logger.info(`GenerateContent request (${version})`, { - model: model, + model, userPromptId: user_prompt_id, projectId: project || account.projectId, apiKeyId: req.apiKey?.id || 'unknown' - }); + }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken); + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) const response = await geminiAccountService.generateContent( client, { model, request: actualRequestData }, user_prompt_id, project || account.projectId, req.apiKey?.id // 使用 API Key ID 作为 session ID - ); + ) // 记录使用统计 if (response?.response?.usageMetadata) { try { - const usage = response.response.usageMetadata; + const usage = response.response.usageMetadata await apiKeyService.recordUsage( req.apiKey.id, usage.promptTokenCount || 0, @@ -489,42 +523,45 @@ async function handleGenerateContent(req, res) { 0, // cacheReadTokens model, account.id - ); - logger.info(`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`); + ) + logger.info( + `📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}` + ) } catch (error) { - logger.error('Failed to record Gemini usage:', error); + logger.error('Failed to record Gemini usage:', error) } } - res.json(response); + res.json(response) } catch (error) { - console.log(321, error.response); - const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'; - logger.error(`Error in generateContent endpoint (${version})`, { error: error.message }); + console.log(321, error.response) + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in generateContent endpoint (${version})`, { error: error.message }) res.status(500).json({ error: { message: error.message || 'Internal server error', type: 'api_error' } - }); + }) } + return undefined } // 共用的 streamGenerateContent 处理函数 async function handleStreamGenerateContent(req, res) { - let abortController = null; + let abortController = null try { - const { model, project, user_prompt_id, request: requestData } = req.body; - const sessionHash = sessionHelper.generateSessionHash(req.body); + const { model, project, user_prompt_id, request: requestData } = req.body + const sessionHash = sessionHelper.generateSessionHash(req.body) // 处理不同格式的请求 - let actualRequestData = requestData; + let actualRequestData = requestData if (!requestData) { if (req.body.messages) { // 这是 OpenAI 格式的请求,构建 Gemini 格式的 request 对象 actualRequestData = { - contents: req.body.messages.map(msg => ({ + contents: req.body.messages.map((msg) => ({ role: msg.role === 'assistant' ? 'model' : msg.role, parts: [{ text: msg.content }] })), @@ -534,10 +571,10 @@ async function handleStreamGenerateContent(req, res) { topP: req.body.top_p !== undefined ? req.body.top_p : 0.95, topK: req.body.top_k !== undefined ? req.body.top_k : 40 } - }; + } } else if (req.body.contents) { // 直接的 Gemini 格式请求(没有 request 包装) - actualRequestData = req.body; + actualRequestData = req.body } } @@ -548,34 +585,38 @@ async function handleStreamGenerateContent(req, res) { message: 'Request contents are required', type: 'invalid_request_error' } - }); + }) } // 使用统一调度选择账号 - const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(req.apiKey, sessionHash, model); - const account = await geminiAccountService.getAccount(accountId); - const { accessToken, refreshToken } = account; + const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model + ) + const account = await geminiAccountService.getAccount(accountId) + const { accessToken, refreshToken } = account - const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'; + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' logger.info(`StreamGenerateContent request (${version})`, { - model: model, + model, userPromptId: user_prompt_id, projectId: project || account.projectId, apiKeyId: req.apiKey?.id || 'unknown' - }); + }) // 创建中止控制器 - abortController = new AbortController(); + abortController = new AbortController() // 处理客户端断开连接 req.on('close', () => { if (abortController && !abortController.signal.aborted) { - logger.info('Client disconnected, aborting stream request'); - abortController.abort(); + logger.info('Client disconnected, aborting stream request') + abortController.abort() } - }); + }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken); + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) const streamResponse = await geminiAccountService.generateContentStream( client, { model, request: actualRequestData }, @@ -583,48 +624,48 @@ async function handleStreamGenerateContent(req, res) { project || account.projectId, req.apiKey?.id, // 使用 API Key ID 作为 session ID abortController.signal // 传递中止信号 - ); + ) // 设置 SSE 响应头 - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') // 处理流式响应并捕获usage数据 - let buffer = ''; + let buffer = '' let totalUsage = { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 - }; - let usageReported = false; + } + const usageReported = false streamResponse.on('data', (chunk) => { try { - const chunkStr = chunk.toString(); - + const chunkStr = chunk.toString() + // 直接转发数据到客户端 if (!res.destroyed) { - res.write(chunkStr); + res.write(chunkStr) } // 同时解析数据以捕获usage信息 - buffer += chunkStr; - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; + buffer += chunkStr + const lines = buffer.split('\n') + buffer = lines.pop() || '' for (const line of lines) { if (line.startsWith('data: ') && line.length > 6) { try { - const jsonStr = line.slice(6); + const jsonStr = line.slice(6) if (jsonStr && jsonStr !== '[DONE]') { - const data = JSON.parse(jsonStr); - + const data = JSON.parse(jsonStr) + // 从响应中提取usage数据 if (data.response?.usageMetadata) { - totalUsage = data.response.usageMetadata; - logger.debug('📊 Captured Gemini usage data:', totalUsage); + totalUsage = data.response.usageMetadata + logger.debug('📊 Captured Gemini usage data:', totalUsage) } } } catch (e) { @@ -633,13 +674,13 @@ async function handleStreamGenerateContent(req, res) { } } } catch (error) { - logger.error('Error processing stream chunk:', error); + logger.error('Error processing stream chunk:', error) } - }); + }) streamResponse.on('end', async () => { - logger.info('Stream completed successfully'); - + logger.info('Stream completed successfully') + // 记录使用统计 if (!usageReported && totalUsage.totalTokenCount > 0) { try { @@ -651,33 +692,34 @@ async function handleStreamGenerateContent(req, res) { 0, // cacheReadTokens model, account.id - ); - logger.info(`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`); + ) + logger.info( + `📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}` + ) } catch (error) { - logger.error('Failed to record Gemini usage:', error); + logger.error('Failed to record Gemini usage:', error) } } - - res.end(); - }); + + res.end() + }) streamResponse.on('error', (error) => { - logger.error('Stream error:', error); + logger.error('Stream error:', error) if (!res.headersSent) { res.status(500).json({ error: { message: error.message || 'Stream error', type: 'api_error' } - }); + }) } else { - res.end(); + res.end() } - }); - + }) } catch (error) { - const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'; - logger.error(`Error in streamGenerateContent endpoint (${version})`, { error: error.message }); + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in streamGenerateContent endpoint (${version})`, { error: error.message }) if (!res.headersSent) { res.status(500).json({ @@ -685,29 +727,38 @@ async function handleStreamGenerateContent(req, res) { message: error.message || 'Internal server error', type: 'api_error' } - }); + }) } } finally { // 清理资源 if (abortController) { - abortController = null; + abortController = null } } + return undefined } // 注册所有路由端点 // v1internal 版本的端点 -router.post('/v1internal\\:loadCodeAssist', authenticateApiKey, handleLoadCodeAssist); -router.post('/v1internal\\:onboardUser', authenticateApiKey, handleOnboardUser); -router.post('/v1internal\\:countTokens', authenticateApiKey, handleCountTokens); -router.post('/v1internal\\:generateContent', authenticateApiKey, handleGenerateContent); -router.post('/v1internal\\:streamGenerateContent', authenticateApiKey, handleStreamGenerateContent); +router.post('/v1internal\\:loadCodeAssist', authenticateApiKey, handleLoadCodeAssist) +router.post('/v1internal\\:onboardUser', authenticateApiKey, handleOnboardUser) +router.post('/v1internal\\:countTokens', authenticateApiKey, handleCountTokens) +router.post('/v1internal\\:generateContent', authenticateApiKey, handleGenerateContent) +router.post('/v1internal\\:streamGenerateContent', authenticateApiKey, handleStreamGenerateContent) // v1beta 版本的端点 - 支持动态模型名称 -router.post('/v1beta/models/:modelName\\:loadCodeAssist', authenticateApiKey, handleLoadCodeAssist); -router.post('/v1beta/models/:modelName\\:onboardUser', authenticateApiKey, handleOnboardUser); -router.post('/v1beta/models/:modelName\\:countTokens', authenticateApiKey, handleCountTokens); -router.post('/v1beta/models/:modelName\\:generateContent', authenticateApiKey, handleGenerateContent); -router.post('/v1beta/models/:modelName\\:streamGenerateContent', authenticateApiKey, handleStreamGenerateContent); +router.post('/v1beta/models/:modelName\\:loadCodeAssist', authenticateApiKey, handleLoadCodeAssist) +router.post('/v1beta/models/:modelName\\:onboardUser', authenticateApiKey, handleOnboardUser) +router.post('/v1beta/models/:modelName\\:countTokens', authenticateApiKey, handleCountTokens) +router.post( + '/v1beta/models/:modelName\\:generateContent', + authenticateApiKey, + handleGenerateContent +) +router.post( + '/v1beta/models/:modelName\\:streamGenerateContent', + authenticateApiKey, + handleStreamGenerateContent +) -module.exports = router; \ No newline at end of file +module.exports = router diff --git a/src/routes/openaiClaudeRoutes.js b/src/routes/openaiClaudeRoutes.js index 6b36f1fb..63308a72 100644 --- a/src/routes/openaiClaudeRoutes.js +++ b/src/routes/openaiClaudeRoutes.js @@ -3,41 +3,41 @@ * 提供 OpenAI 格式的 API 接口,内部转发到 Claude */ -const express = require('express'); -const router = express.Router(); -const fs = require('fs'); -const path = require('path'); -const logger = require('../utils/logger'); -const { authenticateApiKey } = require('../middleware/auth'); -const claudeRelayService = require('../services/claudeRelayService'); -const openaiToClaude = require('../services/openaiToClaude'); -const apiKeyService = require('../services/apiKeyService'); -const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler'); -const claudeCodeHeadersService = require('../services/claudeCodeHeadersService'); -const sessionHelper = require('../utils/sessionHelper'); +const express = require('express') +const router = express.Router() +const fs = require('fs') +const path = require('path') +const logger = require('../utils/logger') +const { authenticateApiKey } = require('../middleware/auth') +const claudeRelayService = require('../services/claudeRelayService') +const openaiToClaude = require('../services/openaiToClaude') +const apiKeyService = require('../services/apiKeyService') +const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler') +const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') +const sessionHelper = require('../utils/sessionHelper') // 加载模型定价数据 -let modelPricingData = {}; +let modelPricingData = {} try { - const pricingPath = path.join(__dirname, '../../data/model_pricing.json'); - const pricingContent = fs.readFileSync(pricingPath, 'utf8'); - modelPricingData = JSON.parse(pricingContent); - logger.info('✅ Model pricing data loaded successfully'); + const pricingPath = path.join(__dirname, '../../data/model_pricing.json') + const pricingContent = fs.readFileSync(pricingPath, 'utf8') + modelPricingData = JSON.parse(pricingContent) + logger.info('✅ Model pricing data loaded successfully') } catch (error) { - logger.error('❌ Failed to load model pricing data:', error); + logger.error('❌ Failed to load model pricing data:', error) } // 🔧 辅助函数:检查 API Key 权限 function checkPermissions(apiKeyData, requiredPermission = 'claude') { - const permissions = apiKeyData.permissions || 'all'; - return permissions === 'all' || permissions === requiredPermission; + const permissions = apiKeyData.permissions || 'all' + return permissions === 'all' || permissions === requiredPermission } // 📋 OpenAI 兼容的模型列表端点 router.get('/v1/models', authenticateApiKey, async (req, res) => { try { - const apiKeyData = req.apiKey; - + const apiKeyData = req.apiKey + // 检查权限 if (!checkPermissions(apiKeyData, 'claude')) { return res.status(403).json({ @@ -46,9 +46,9 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { type: 'permission_denied', code: 'permission_denied' } - }); + }) } - + // Claude 模型列表 - 只返回 opus-4 和 sonnet-4 let models = [ { @@ -63,36 +63,36 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { created: 1736726400, // 2025-01-13 owned_by: 'anthropic' } - ]; - + ] + // 如果启用了模型限制,过滤模型列表 if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) { - models = models.filter(model => apiKeyData.restrictedModels.includes(model.id)); + models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id)) } - + res.json({ object: 'list', data: models - }); - + }) } catch (error) { - logger.error('❌ Failed to get OpenAI-Claude models:', error); + logger.error('❌ Failed to get OpenAI-Claude models:', error) res.status(500).json({ error: { message: 'Failed to retrieve models', type: 'server_error', code: 'internal_error' } - }); + }) } -}); + return undefined +}) // 📄 OpenAI 兼容的模型详情端点 router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { try { - const apiKeyData = req.apiKey; - const modelId = req.params.model; - + const apiKeyData = req.apiKey + const modelId = req.params.model + // 检查权限 if (!checkPermissions(apiKeyData, 'claude')) { return res.status(403).json({ @@ -101,9 +101,9 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { type: 'permission_denied', code: 'permission_denied' } - }); + }) } - + // 检查模型限制 if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) { if (!apiKeyData.restrictedModels.includes(modelId)) { @@ -113,16 +113,16 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { type: 'invalid_request_error', code: 'model_not_found' } - }); + }) } } - + // 从 model_pricing.json 获取模型信息 - const modelData = modelPricingData[modelId]; - + const modelData = modelPricingData[modelId] + // 构建标准 OpenAI 格式的模型响应 - let modelInfo; - + let modelInfo + if (modelData) { // 如果在 pricing 文件中找到了模型 modelInfo = { @@ -133,7 +133,7 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { permission: [], root: modelId, parent: null - }; + } } else { // 如果没找到,返回默认信息(但仍保持正确格式) modelInfo = { @@ -144,28 +144,28 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { permission: [], root: modelId, parent: null - }; + } } - - res.json(modelInfo); - + + res.json(modelInfo) } catch (error) { - logger.error('❌ Failed to get model details:', error); + logger.error('❌ Failed to get model details:', error) res.status(500).json({ error: { message: 'Failed to retrieve model details', type: 'server_error', code: 'internal_error' } - }); + }) } -}); + return undefined +}) // 🔧 处理聊天完成请求的核心函数 async function handleChatCompletion(req, res, apiKeyData) { - const startTime = Date.now(); - let abortController = null; - + const startTime = Date.now() + let abortController = null + try { // 检查权限 if (!checkPermissions(apiKeyData, 'claude')) { @@ -175,20 +175,20 @@ async function handleChatCompletion(req, res, apiKeyData) { type: 'permission_denied', code: 'permission_denied' } - }); + }) } - + // 记录原始请求 logger.debug('📥 Received OpenAI format request:', { model: req.body.model, messageCount: req.body.messages?.length, stream: req.body.stream, maxTokens: req.body.max_tokens - }); - + }) + // 转换 OpenAI 请求为 Claude 格式 - const claudeRequest = openaiToClaude.convertRequest(req.body); - + const claudeRequest = openaiToClaude.convertRequest(req.body) + // 检查模型限制 if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) { if (!apiKeyData.restrictedModels.includes(claudeRequest.model)) { @@ -198,114 +198,119 @@ async function handleChatCompletion(req, res, apiKeyData) { type: 'invalid_request_error', code: 'model_not_allowed' } - }); + }) } } - + // 生成会话哈希用于sticky会话 - const sessionHash = sessionHelper.generateSessionHash(claudeRequest); - + const sessionHash = sessionHelper.generateSessionHash(claudeRequest) + // 选择可用的Claude账户 - const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(apiKeyData, sessionHash, claudeRequest.model); - const accountId = accountSelection.accountId; - + const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + claudeRequest.model + ) + const { accountId } = accountSelection + // 获取该账号存储的 Claude Code headers - const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId); - + const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) + logger.debug(`📋 Using Claude Code headers for account ${accountId}:`, { userAgent: claudeCodeHeaders['user-agent'] - }); - + }) + // 处理流式请求 if (claudeRequest.stream) { - logger.info(`🌊 Processing OpenAI stream request for model: ${req.body.model}`); - + logger.info(`🌊 Processing OpenAI stream request for model: ${req.body.model}`) + // 设置 SSE 响应头 - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - - + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + // 创建中止控制器 - abortController = new AbortController(); - + abortController = new AbortController() + // 处理客户端断开 req.on('close', () => { if (abortController && !abortController.signal.aborted) { - logger.info('🔌 Client disconnected, aborting Claude request'); - abortController.abort(); + logger.info('🔌 Client disconnected, aborting Claude request') + abortController.abort() } - }); - + }) + // 使用转换后的响应流 (使用 OAuth-only beta header,添加 Claude Code 必需的 headers) await claudeRelayService.relayStreamRequestWithUsageCapture( - claudeRequest, - apiKeyData, - res, + claudeRequest, + apiKeyData, + res, claudeCodeHeaders, (usage) => { // 记录使用统计 if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) { - const inputTokens = usage.input_tokens || 0; - const outputTokens = usage.output_tokens || 0; - const cacheCreateTokens = usage.cache_creation_input_tokens || 0; - const cacheReadTokens = usage.cache_read_input_tokens || 0; - const model = usage.model || claudeRequest.model; - - apiKeyService.recordUsage( - apiKeyData.id, - inputTokens, - outputTokens, - cacheCreateTokens, - cacheReadTokens, - model, - accountId - ).catch(error => { - logger.error('❌ Failed to record usage:', error); - }); + const inputTokens = usage.input_tokens || 0 + const outputTokens = usage.output_tokens || 0 + const cacheCreateTokens = usage.cache_creation_input_tokens || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + const model = usage.model || claudeRequest.model + + apiKeyService + .recordUsage( + apiKeyData.id, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model, + accountId + ) + .catch((error) => { + logger.error('❌ Failed to record usage:', error) + }) } }, // 流转换器 (() => { // 为每个请求创建独立的会话ID - const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`; - return (chunk) => { - return openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId); - }; + const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}` + return (chunk) => openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId) })(), - { betaHeader: 'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14' } - ); - + { + betaHeader: + 'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14' + } + ) } else { // 非流式请求 - logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`); - + logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`) + // 发送请求到 Claude (使用 OAuth-only beta header,添加 Claude Code 必需的 headers) const claudeResponse = await claudeRelayService.relayRequest( - claudeRequest, - apiKeyData, - req, - res, + claudeRequest, + apiKeyData, + req, + res, claudeCodeHeaders, { betaHeader: 'oauth-2025-04-20' } - ); - + ) + // 解析 Claude 响应 - let claudeData; + let claudeData try { - claudeData = JSON.parse(claudeResponse.body); + claudeData = JSON.parse(claudeResponse.body) } catch (error) { - logger.error('❌ Failed to parse Claude response:', error); + logger.error('❌ Failed to parse Claude response:', error) return res.status(502).json({ error: { message: 'Invalid response from Claude API', type: 'api_error', code: 'invalid_response' } - }); + }) } - + // 处理错误响应 if (claudeResponse.statusCode >= 400) { return res.status(claudeResponse.statusCode).json({ @@ -314,64 +319,66 @@ async function handleChatCompletion(req, res, apiKeyData) { type: claudeData.error?.type || 'api_error', code: claudeData.error?.code || 'unknown_error' } - }); + }) } - + // 转换为 OpenAI 格式 - const openaiResponse = openaiToClaude.convertResponse(claudeData, req.body.model); - + const openaiResponse = openaiToClaude.convertResponse(claudeData, req.body.model) + // 记录使用统计 if (claudeData.usage) { - const usage = claudeData.usage; - apiKeyService.recordUsage( - apiKeyData.id, - usage.input_tokens || 0, - usage.output_tokens || 0, - usage.cache_creation_input_tokens || 0, - usage.cache_read_input_tokens || 0, - claudeRequest.model, - accountId - ).catch(error => { - logger.error('❌ Failed to record usage:', error); - }); + const { usage } = claudeData + apiKeyService + .recordUsage( + apiKeyData.id, + usage.input_tokens || 0, + usage.output_tokens || 0, + usage.cache_creation_input_tokens || 0, + usage.cache_read_input_tokens || 0, + claudeRequest.model, + accountId + ) + .catch((error) => { + logger.error('❌ Failed to record usage:', error) + }) } - + // 返回 OpenAI 格式响应 - res.json(openaiResponse); + res.json(openaiResponse) } - - const duration = Date.now() - startTime; - logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`); - + + const duration = Date.now() - startTime + logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`) } catch (error) { - logger.error('❌ OpenAI-Claude request error:', error); - - const status = error.status || 500; + logger.error('❌ OpenAI-Claude request error:', error) + + const status = error.status || 500 res.status(status).json({ error: { message: error.message || 'Internal server error', type: 'server_error', code: 'internal_error' } - }); + }) } finally { // 清理资源 if (abortController) { - abortController = null; + abortController = null } } + return undefined } // 🚀 OpenAI 兼容的聊天完成端点 router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { - await handleChatCompletion(req, res, req.apiKey); -}); + await handleChatCompletion(req, res, req.apiKey) +}) // 🔧 OpenAI 兼容的 completions 端点(传统格式,转换为 chat 格式) router.post('/v1/completions', authenticateApiKey, async (req, res) => { try { - const apiKeyData = req.apiKey; - + const apiKeyData = req.apiKey + // 验证必需参数 if (!req.body.prompt) { return res.status(400).json({ @@ -380,11 +387,11 @@ router.post('/v1/completions', authenticateApiKey, async (req, res) => { type: 'invalid_request_error', code: 'invalid_request' } - }); + }) } - + // 将传统 completions 格式转换为 chat 格式 - const originalBody = req.body; + const originalBody = req.body req.body = { model: originalBody.model, messages: [ @@ -403,21 +410,21 @@ router.post('/v1/completions', authenticateApiKey, async (req, res) => { frequency_penalty: originalBody.frequency_penalty, logit_bias: originalBody.logit_bias, user: originalBody.user - }; - + } + // 使用共享的处理函数 - await handleChatCompletion(req, res, apiKeyData); - + await handleChatCompletion(req, res, apiKeyData) } catch (error) { - logger.error('❌ OpenAI completions error:', error); + logger.error('❌ OpenAI completions error:', error) res.status(500).json({ error: { message: 'Failed to process completion request', type: 'server_error', code: 'internal_error' } - }); + }) } -}); + return undefined +}) -module.exports = router; \ No newline at end of file +module.exports = router diff --git a/src/routes/openaiGeminiRoutes.js b/src/routes/openaiGeminiRoutes.js index 9a9714e2..5e304f06 100644 --- a/src/routes/openaiGeminiRoutes.js +++ b/src/routes/openaiGeminiRoutes.js @@ -1,11 +1,11 @@ -const express = require('express'); -const router = express.Router(); -const logger = require('../utils/logger'); -const { authenticateApiKey } = require('../middleware/auth'); -const geminiAccountService = require('../services/geminiAccountService'); -const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler'); -const { getAvailableModels } = require('../services/geminiRelayService'); -const crypto = require('crypto'); +const express = require('express') +const router = express.Router() +const logger = require('../utils/logger') +const { authenticateApiKey } = require('../middleware/auth') +const geminiAccountService = require('../services/geminiAccountService') +const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') +const { getAvailableModels } = require('../services/geminiRelayService') +const crypto = require('crypto') // 生成会话哈希 function generateSessionHash(req) { @@ -13,167 +13,182 @@ function generateSessionHash(req) { req.headers['user-agent'], req.ip, req.headers['authorization']?.substring(0, 20) - ].filter(Boolean).join(':'); - - return crypto.createHash('sha256').update(sessionData).digest('hex'); + ] + .filter(Boolean) + .join(':') + + return crypto.createHash('sha256').update(sessionData).digest('hex') } // 检查 API Key 权限 function checkPermissions(apiKeyData, requiredPermission = 'gemini') { - const permissions = apiKeyData.permissions || 'all'; - return permissions === 'all' || permissions === requiredPermission; + const permissions = apiKeyData.permissions || 'all' + return permissions === 'all' || permissions === requiredPermission } // 转换 OpenAI 消息格式到 Gemini 格式 function convertMessagesToGemini(messages) { - const contents = []; - let systemInstruction = ''; - + const contents = [] + let systemInstruction = '' + // 辅助函数:提取文本内容 function extractTextContent(content) { // 处理 null 或 undefined - if (content == null) { - return ''; + if (content === null || content === undefined) { + return '' } - + // 处理字符串 if (typeof content === 'string') { - return content; + return content } - + // 处理数组格式的内容 if (Array.isArray(content)) { - return content.map(item => { - if (item == null) return ''; - if (typeof item === 'string') { - return item; - } - if (typeof item === 'object') { - // 处理 {type: 'text', text: '...'} 格式 - if (item.type === 'text' && item.text) { - return item.text; + return content + .map((item) => { + if (item === null || item === undefined) { + return '' } - // 处理 {text: '...'} 格式 - if (item.text) { - return item.text; + if (typeof item === 'string') { + return item } - // 处理嵌套的对象或数组 - if (item.content) { - return extractTextContent(item.content); + if (typeof item === 'object') { + // 处理 {type: 'text', text: '...'} 格式 + if (item.type === 'text' && item.text) { + return item.text + } + // 处理 {text: '...'} 格式 + if (item.text) { + return item.text + } + // 处理嵌套的对象或数组 + if (item.content) { + return extractTextContent(item.content) + } } - } - return ''; - }).join(''); + return '' + }) + .join('') } - + // 处理对象格式的内容 if (typeof content === 'object') { // 处理 {text: '...'} 格式 if (content.text) { - return content.text; + return content.text } // 处理 {content: '...'} 格式 if (content.content) { - return extractTextContent(content.content); + return extractTextContent(content.content) } // 处理 {parts: [{text: '...'}]} 格式 if (content.parts && Array.isArray(content.parts)) { - return content.parts.map(part => { - if (part && part.text) { - return part.text; - } - return ''; - }).join(''); + return content.parts + .map((part) => { + if (part && part.text) { + return part.text + } + return '' + }) + .join('') } } - + // 最后的后备选项:只有在内容确实不为空且有意义时才转换为字符串 - if (content !== undefined && content !== null && content !== '' && typeof content !== 'object') { - return String(content); + if ( + content !== undefined && + content !== null && + content !== '' && + typeof content !== 'object' + ) { + return String(content) } - - return ''; + + return '' } - + for (const message of messages) { - const textContent = extractTextContent(message.content); - + const textContent = extractTextContent(message.content) + if (message.role === 'system') { - systemInstruction += (systemInstruction ? '\n\n' : '') + textContent; + systemInstruction += (systemInstruction ? '\n\n' : '') + textContent } else if (message.role === 'user') { contents.push({ role: 'user', parts: [{ text: textContent }] - }); + }) } else if (message.role === 'assistant') { contents.push({ role: 'model', parts: [{ text: textContent }] - }); + }) } } - - return { contents, systemInstruction }; + + return { contents, systemInstruction } } // 转换 Gemini 响应到 OpenAI 格式 function convertGeminiResponseToOpenAI(geminiResponse, model, stream = false) { if (stream) { // 处理流式响应 - 原样返回 SSE 数据 - return geminiResponse; + return geminiResponse } else { // 非流式响应转换 // 处理嵌套的 response 结构 - const actualResponse = geminiResponse.response || geminiResponse; - + const actualResponse = geminiResponse.response || geminiResponse + if (actualResponse.candidates && actualResponse.candidates.length > 0) { - const candidate = actualResponse.candidates[0]; - const content = candidate.content?.parts?.[0]?.text || ''; - const finishReason = candidate.finishReason?.toLowerCase() || 'stop'; + const candidate = actualResponse.candidates[0] + const content = candidate.content?.parts?.[0]?.text || '' + const finishReason = candidate.finishReason?.toLowerCase() || 'stop' // 计算 token 使用量 const usage = actualResponse.usageMetadata || { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 - }; + } return { id: `chatcmpl-${Date.now()}`, object: 'chat.completion', created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - message: { - role: 'assistant', - content: content - }, - finish_reason: finishReason - }], + model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content + }, + finish_reason: finishReason + } + ], usage: { prompt_tokens: usage.promptTokenCount, completion_tokens: usage.candidatesTokenCount, total_tokens: usage.totalTokenCount } - }; + } } else { - throw new Error('No response from Gemini'); + throw new Error('No response from Gemini') } } } // OpenAI 兼容的聊天完成端点 router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { - const startTime = Date.now(); - let abortController = null; - let account = null; // Declare account outside try block for error handling - let accountSelection = null; // Declare accountSelection for error handling - let sessionHash = null; // Declare sessionHash for error handling - + const startTime = Date.now() + let abortController = null + let account = null // Declare account outside try block for error handling + let accountSelection = null // Declare accountSelection for error handling + let sessionHash = null // Declare sessionHash for error handling + try { - const apiKeyData = req.apiKey; - + const apiKeyData = req.apiKey + // 检查权限 if (!checkPermissions(apiKeyData, 'gemini')) { return res.status(403).json({ @@ -182,25 +197,25 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { type: 'permission_denied', code: 'permission_denied' } - }); + }) } // 处理请求体结构 - 支持多种格式 - let requestBody = req.body; - + let requestBody = req.body + // 如果请求体被包装在 body 字段中,解包它 if (req.body.body && typeof req.body.body === 'object') { - requestBody = req.body.body; + requestBody = req.body.body } - + // 从 URL 路径中提取模型信息(如果存在) - let urlModel = null; - const urlPath = req.body?.config?.url || req.originalUrl || req.url; - const modelMatch = urlPath.match(/\/([^/]+):(?:stream)?[Gg]enerateContent/); + let urlModel = null + const urlPath = req.body?.config?.url || req.originalUrl || req.url + const modelMatch = urlPath.match(/\/([^/]+):(?:stream)?[Gg]enerateContent/) if (modelMatch) { - urlModel = modelMatch[1]; - logger.debug(`Extracted model from URL: ${urlModel}`); + urlModel = modelMatch[1] + logger.debug(`Extracted model from URL: ${urlModel}`) } - + // 提取请求参数 const { messages: requestMessages, @@ -209,19 +224,19 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { temperature = 0.7, max_tokens = 4096, stream = false - } = requestBody; - + } = requestBody + // 检查URL中是否包含stream标识 - const isStreamFromUrl = urlPath && urlPath.includes('streamGenerateContent'); - const actualStream = stream || isStreamFromUrl; + const isStreamFromUrl = urlPath && urlPath.includes('streamGenerateContent') + const actualStream = stream || isStreamFromUrl // 优先使用 URL 中的模型,其次是请求体中的模型 - const model = urlModel || bodyModel; + const model = urlModel || bodyModel // 支持两种格式: OpenAI 的 messages 或 Gemini 的 contents - let messages = requestMessages; + let messages = requestMessages if (requestContents && Array.isArray(requestContents)) { - messages = requestContents; + messages = requestContents } // 验证必需参数 @@ -232,9 +247,9 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { type: 'invalid_request_error', code: 'invalid_request' } - }); + }) } - + // 检查模型限制 if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) { if (!apiKeyData.restrictedModels.includes(model)) { @@ -244,13 +259,13 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { type: 'invalid_request_error', code: 'model_not_allowed' } - }); + }) } } - + // 转换消息格式 - const { contents: geminiContents, systemInstruction } = convertMessagesToGemini(messages); - + const { contents: geminiContents, systemInstruction } = convertMessagesToGemini(messages) + // 构建 Gemini 请求体 const geminiRequestBody = { contents: geminiContents, @@ -259,24 +274,28 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { maxOutputTokens: max_tokens, candidateCount: 1 } - }; - - if (systemInstruction) { - geminiRequestBody.systemInstruction = { parts: [{ text: systemInstruction }] }; } - + + if (systemInstruction) { + geminiRequestBody.systemInstruction = { parts: [{ text: systemInstruction }] } + } + // 生成会话哈希用于粘性会话 - sessionHash = generateSessionHash(req); - + sessionHash = generateSessionHash(req) + // 选择可用的 Gemini 账户 try { - accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey(apiKeyData, sessionHash, model); - account = await geminiAccountService.getAccount(accountSelection.accountId); + accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + model + ) + account = await geminiAccountService.getAccount(accountSelection.accountId) } catch (error) { - logger.error('Failed to select Gemini account:', error); - account = null; + logger.error('Failed to select Gemini account:', error) + account = null } - + if (!account) { return res.status(503).json({ error: { @@ -284,35 +303,38 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { type: 'service_unavailable', code: 'service_unavailable' } - }); + }) } - - logger.info(`Using Gemini account: ${account.id} for API key: ${apiKeyData.id}`); - + + logger.info(`Using Gemini account: ${account.id} for API key: ${apiKeyData.id}`) + // 标记账户被使用 - await geminiAccountService.markAccountUsed(account.id); - + await geminiAccountService.markAccountUsed(account.id) + // 创建中止控制器 - abortController = new AbortController(); - + abortController = new AbortController() + // 处理客户端断开连接 req.on('close', () => { if (abortController && !abortController.signal.aborted) { - logger.info('Client disconnected, aborting Gemini request'); - abortController.abort(); + logger.info('Client disconnected, aborting Gemini request') + abortController.abort() } - }); - + }) + // 获取OAuth客户端 - const client = await geminiAccountService.getOauthClient(account.accessToken, account.refreshToken); + const client = await geminiAccountService.getOauthClient( + account.accessToken, + account.refreshToken + ) if (actualStream) { // 流式响应 logger.info('StreamGenerateContent request', { - model: model, + model, projectId: account.projectId, apiKeyId: apiKeyData.id - }); - + }) + const streamResponse = await geminiAccountService.generateContentStream( client, { model, request: geminiRequestBody }, @@ -320,93 +342,101 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { account.projectId, // 使用有权限的项目ID apiKeyData.id, // 使用 API Key ID 作为 session ID abortController.signal // 传递中止信号 - ); - + ) + // 设置流式响应头 - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + // 处理流式响应,转换为 OpenAI 格式 - let buffer = ''; - + let buffer = '' + // 发送初始的空消息,符合 OpenAI 流式格式 const initialChunk = { id: `chatcmpl-${Date.now()}`, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - delta: { role: 'assistant' }, - finish_reason: null - }] - }; - res.write(`data: ${JSON.stringify(initialChunk)}\n\n`); - + model, + choices: [ + { + index: 0, + delta: { role: 'assistant' }, + finish_reason: null + } + ] + } + res.write(`data: ${JSON.stringify(initialChunk)}\n\n`) + // 用于收集usage数据 let totalUsage = { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 - }; - let usageReported = false; + } + const usageReported = false streamResponse.on('data', (chunk) => { try { - const chunkStr = chunk.toString(); - + const chunkStr = chunk.toString() + if (!chunkStr.trim()) { - return; + return } - - buffer += chunkStr; - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // 保留最后一个不完整的行 - + + buffer += chunkStr + const lines = buffer.split('\n') + buffer = lines.pop() || '' // 保留最后一个不完整的行 + for (const line of lines) { - if (!line.trim()) continue; - - // 处理 SSE 格式 - let jsonData = line; - if (line.startsWith('data: ')) { - jsonData = line.substring(6).trim(); + if (!line.trim()) { + continue } - - if (!jsonData || jsonData === '[DONE]') continue; - + + // 处理 SSE 格式 + let jsonData = line + if (line.startsWith('data: ')) { + jsonData = line.substring(6).trim() + } + + if (!jsonData || jsonData === '[DONE]') { + continue + } + try { - const data = JSON.parse(jsonData); - + const data = JSON.parse(jsonData) + // 捕获usage数据 if (data.response?.usageMetadata) { - totalUsage = data.response.usageMetadata; - logger.debug('📊 Captured Gemini usage data:', totalUsage); + totalUsage = data.response.usageMetadata + logger.debug('📊 Captured Gemini usage data:', totalUsage) } - + // 转换为 OpenAI 流式格式 if (data.response?.candidates && data.response.candidates.length > 0) { - const candidate = data.response.candidates[0]; - const content = candidate.content?.parts?.[0]?.text || ''; - const finishReason = candidate.finishReason; - + const candidate = data.response.candidates[0] + const content = candidate.content?.parts?.[0]?.text || '' + const { finishReason } = candidate + // 只有当有内容或者是结束标记时才发送数据 if (content || finishReason === 'STOP') { const openaiChunk = { id: `chatcmpl-${Date.now()}`, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - delta: content ? { content: content } : {}, - finish_reason: finishReason === 'STOP' ? 'stop' : null - }] - }; - - res.write(`data: ${JSON.stringify(openaiChunk)}\n\n`); - + model, + choices: [ + { + index: 0, + delta: content ? { content } : {}, + finish_reason: finishReason === 'STOP' ? 'stop' : null + } + ] + } + + res.write(`data: ${JSON.stringify(openaiChunk)}\n\n`) + // 如果结束了,添加 usage 信息并发送最终的 [DONE] if (finishReason === 'STOP') { // 如果有 usage 数据,添加到最后一个 chunk @@ -415,48 +445,50 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { id: `chatcmpl-${Date.now()}`, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - delta: {}, - finish_reason: 'stop' - }], + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop' + } + ], usage: { prompt_tokens: data.response.usageMetadata.promptTokenCount || 0, completion_tokens: data.response.usageMetadata.candidatesTokenCount || 0, total_tokens: data.response.usageMetadata.totalTokenCount || 0 } - }; - res.write(`data: ${JSON.stringify(usageChunk)}\n\n`); + } + res.write(`data: ${JSON.stringify(usageChunk)}\n\n`) } - res.write('data: [DONE]\n\n'); + res.write('data: [DONE]\n\n') } } } } catch (e) { - logger.debug('Error parsing JSON line:', e.message); + logger.debug('Error parsing JSON line:', e.message) } } } catch (error) { - logger.error('Stream processing error:', error); + logger.error('Stream processing error:', error) if (!res.headersSent) { res.status(500).json({ error: { message: error.message || 'Stream error', type: 'api_error' } - }); + }) } } - }); - + }) + streamResponse.on('end', async () => { - logger.info('Stream completed successfully'); - + logger.info('Stream completed successfully') + // 记录使用统计 if (!usageReported && totalUsage.totalTokenCount > 0) { try { - const apiKeyService = require('../services/apiKeyService'); + const apiKeyService = require('../services/apiKeyService') await apiKeyService.recordUsage( apiKeyData.id, totalUsage.promptTokenCount || 0, @@ -465,59 +497,60 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { 0, // cacheReadTokens model, account.id - ); - logger.info(`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`); + ) + logger.info( + `📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}` + ) } catch (error) { - logger.error('Failed to record Gemini usage:', error); + logger.error('Failed to record Gemini usage:', error) } } - + if (!res.headersSent) { - res.write('data: [DONE]\n\n'); + res.write('data: [DONE]\n\n') } - res.end(); - }); - + res.end() + }) + streamResponse.on('error', (error) => { - logger.error('Stream error:', error); + logger.error('Stream error:', error) if (!res.headersSent) { res.status(500).json({ error: { message: error.message || 'Stream error', type: 'api_error' } - }); + }) } else { // 如果已经开始发送流数据,发送错误事件 - res.write(`data: {"error": {"message": "${error.message || 'Stream error'}"}}\n\n`); - res.write('data: [DONE]\n\n'); - res.end(); + res.write(`data: {"error": {"message": "${error.message || 'Stream error'}"}}\n\n`) + res.write('data: [DONE]\n\n') + res.end() } - }); - + }) } else { // 非流式响应 logger.info('GenerateContent request', { - model: model, + model, projectId: account.projectId, apiKeyId: apiKeyData.id - }); - + }) + const response = await geminiAccountService.generateContent( client, { model, request: geminiRequestBody }, null, // user_prompt_id account.projectId, // 使用有权限的项目ID apiKeyData.id // 使用 API Key ID 作为 session ID - ); - + ) + // 转换为 OpenAI 格式并返回 - const openaiResponse = convertGeminiResponseToOpenAI(response, model, false); - + const openaiResponse = convertGeminiResponseToOpenAI(response, model, false) + // 记录使用统计 if (openaiResponse.usage) { try { - const apiKeyService = require('../services/apiKeyService'); + const apiKeyService = require('../services/apiKeyService') await apiKeyService.recordUsage( apiKeyData.id, openaiResponse.usage.prompt_tokens || 0, @@ -526,53 +559,55 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { 0, // cacheReadTokens model, account.id - ); - logger.info(`📊 Recorded Gemini usage - Input: ${openaiResponse.usage.prompt_tokens}, Output: ${openaiResponse.usage.completion_tokens}, Total: ${openaiResponse.usage.total_tokens}`); + ) + logger.info( + `📊 Recorded Gemini usage - Input: ${openaiResponse.usage.prompt_tokens}, Output: ${openaiResponse.usage.completion_tokens}, Total: ${openaiResponse.usage.total_tokens}` + ) } catch (error) { - logger.error('Failed to record Gemini usage:', error); + logger.error('Failed to record Gemini usage:', error) } } - - res.json(openaiResponse); + + res.json(openaiResponse) } - - const duration = Date.now() - startTime; - logger.info(`OpenAI-Gemini request completed in ${duration}ms`); - + + const duration = Date.now() - startTime + logger.info(`OpenAI-Gemini request completed in ${duration}ms`) } catch (error) { - logger.error('OpenAI-Gemini request error:', error); - + logger.error('OpenAI-Gemini request error:', error) + // 处理速率限制 if (error.status === 429) { if (req.apiKey && account && accountSelection) { - await unifiedGeminiScheduler.markAccountRateLimited(account.id, 'gemini', sessionHash); + await unifiedGeminiScheduler.markAccountRateLimited(account.id, 'gemini', sessionHash) } } - + // 返回 OpenAI 格式的错误响应 - const status = error.status || 500; + const status = error.status || 500 const errorResponse = { error: error.error || { message: error.message || 'Internal server error', type: 'server_error', code: 'internal_error' } - }; - - res.status(status).json(errorResponse); + } + + res.status(status).json(errorResponse) } finally { // 清理资源 if (abortController) { - abortController = null; + abortController = null } } -}); + return undefined +}) // OpenAI 兼容的模型列表端点 router.get('/v1/models', authenticateApiKey, async (req, res) => { try { - const apiKeyData = req.apiKey; - + const apiKeyData = req.apiKey + // 检查权限 if (!checkPermissions(apiKeyData, 'gemini')) { return res.status(403).json({ @@ -581,23 +616,27 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { type: 'permission_denied', code: 'permission_denied' } - }); + }) } - + // 选择账户获取模型列表 - let account = null; + let account = null try { - const accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey(apiKeyData, null, null); - account = await geminiAccountService.getAccount(accountSelection.accountId); + const accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + apiKeyData, + null, + null + ) + account = await geminiAccountService.getAccount(accountSelection.accountId) } catch (error) { - logger.warn('Failed to select Gemini account for models endpoint:', error); + logger.warn('Failed to select Gemini account for models endpoint:', error) } - - let models = []; - + + let models = [] + if (account) { // 获取实际的模型列表 - models = await getAvailableModels(account.accessToken, account.proxy); + models = await getAvailableModels(account.accessToken, account.proxy) } else { // 返回默认模型列表 models = [ @@ -607,37 +646,37 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { created: Math.floor(Date.now() / 1000), owned_by: 'google' } - ]; + ] } - + // 如果启用了模型限制,过滤模型列表 if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) { - models = models.filter(model => apiKeyData.restrictedModels.includes(model.id)); + models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id)) } - + res.json({ object: 'list', data: models - }); - + }) } catch (error) { - logger.error('Failed to get OpenAI-Gemini models:', error); + logger.error('Failed to get OpenAI-Gemini models:', error) res.status(500).json({ error: { message: 'Failed to retrieve models', type: 'server_error', code: 'internal_error' } - }); + }) } -}); + return undefined +}) // OpenAI 兼容的模型详情端点 router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { try { - const apiKeyData = req.apiKey; - const modelId = req.params.model; - + const apiKeyData = req.apiKey + const modelId = req.params.model + // 检查权限 if (!checkPermissions(apiKeyData, 'gemini')) { return res.status(403).json({ @@ -646,9 +685,9 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { type: 'permission_denied', code: 'permission_denied' } - }); + }) } - + // 检查模型限制 if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) { if (!apiKeyData.restrictedModels.includes(modelId)) { @@ -658,10 +697,10 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { type: 'invalid_request_error', code: 'model_not_found' } - }); + }) } } - + // 返回模型信息 res.json({ id: modelId, @@ -671,18 +710,18 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { permission: [], root: modelId, parent: null - }); - + }) } catch (error) { - logger.error('Failed to get model details:', error); + logger.error('Failed to get model details:', error) res.status(500).json({ error: { message: 'Failed to retrieve model details', type: 'server_error', code: 'internal_error' } - }); + }) } -}); + return undefined +}) -module.exports = router; \ No newline at end of file +module.exports = router diff --git a/src/routes/web.js b/src/routes/web.js index 4cd775bb..8bbdd435 100644 --- a/src/routes/web.js +++ b/src/routes/web.js @@ -1,158 +1,157 @@ -const express = require('express'); -const bcrypt = require('bcryptjs'); -const crypto = require('crypto'); -const path = require('path'); -const fs = require('fs'); -const redis = require('../models/redis'); -const logger = require('../utils/logger'); -const config = require('../../config/config'); +const express = require('express') +const bcrypt = require('bcryptjs') +const crypto = require('crypto') +const path = require('path') +const fs = require('fs') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const config = require('../../config/config') -const router = express.Router(); +const router = express.Router() // 🏠 服务静态文件 -router.use('/assets', express.static(path.join(__dirname, '../../web/assets'))); +router.use('/assets', express.static(path.join(__dirname, '../../web/assets'))) // 🌐 页面路由重定向到新版 admin-spa router.get('/', (req, res) => { - res.redirect(301, '/admin-next/api-stats'); -}); + res.redirect(301, '/admin-next/api-stats') +}) // 🔐 管理员登录 router.post('/auth/login', async (req, res) => { try { - const { username, password } = req.body; + const { username, password } = req.body if (!username || !password) { return res.status(400).json({ error: 'Missing credentials', message: 'Username and password are required' - }); + }) } // 从Redis获取管理员信息 - let adminData = await redis.getSession('admin_credentials'); - + let adminData = await redis.getSession('admin_credentials') + // 如果Redis中没有管理员凭据,尝试从init.json重新加载 if (!adminData || Object.keys(adminData).length === 0) { - const initFilePath = path.join(__dirname, '../../data/init.json'); - + const initFilePath = path.join(__dirname, '../../data/init.json') + if (fs.existsSync(initFilePath)) { try { - const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')); - const saltRounds = 10; - const passwordHash = await bcrypt.hash(initData.adminPassword, saltRounds); - + const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')) + const saltRounds = 10 + const passwordHash = await bcrypt.hash(initData.adminPassword, saltRounds) + adminData = { username: initData.adminUsername, - passwordHash: passwordHash, + passwordHash, createdAt: initData.initializedAt || new Date().toISOString(), lastLogin: null, updatedAt: initData.updatedAt || null - }; - + } + // 重新存储到Redis,不设置过期时间 - await redis.getClient().hset('session:admin_credentials', adminData); - - logger.info('✅ Admin credentials reloaded from init.json'); + await redis.getClient().hset('session:admin_credentials', adminData) + + logger.info('✅ Admin credentials reloaded from init.json') } catch (error) { - logger.error('❌ Failed to reload admin credentials:', error); + logger.error('❌ Failed to reload admin credentials:', error) return res.status(401).json({ error: 'Invalid credentials', message: 'Invalid username or password' - }); + }) } } else { return res.status(401).json({ error: 'Invalid credentials', message: 'Invalid username or password' - }); + }) } } // 验证用户名和密码 - const isValidUsername = adminData.username === username; - const isValidPassword = await bcrypt.compare(password, adminData.passwordHash); + const isValidUsername = adminData.username === username + const isValidPassword = await bcrypt.compare(password, adminData.passwordHash) if (!isValidUsername || !isValidPassword) { - logger.security(`🔒 Failed login attempt for username: ${username}`); + logger.security(`🔒 Failed login attempt for username: ${username}`) return res.status(401).json({ error: 'Invalid credentials', message: 'Invalid username or password' - }); + }) } // 生成会话token - const sessionId = crypto.randomBytes(32).toString('hex'); - + const sessionId = crypto.randomBytes(32).toString('hex') + // 存储会话 const sessionData = { username: adminData.username, loginTime: new Date().toISOString(), lastActivity: new Date().toISOString() - }; - - await redis.setSession(sessionId, sessionData, config.security.adminSessionTimeout); - + } + + await redis.setSession(sessionId, sessionData, config.security.adminSessionTimeout) + // 不再更新 Redis 中的最后登录时间,因为 Redis 只是缓存 // init.json 是唯一真实数据源 - logger.success(`🔐 Admin login successful: ${username}`); + logger.success(`🔐 Admin login successful: ${username}`) - res.json({ + return res.json({ success: true, token: sessionId, expiresIn: config.security.adminSessionTimeout, username: adminData.username // 返回真实用户名 - }); - + }) } catch (error) { - logger.error('❌ Login error:', error); - res.status(500).json({ + logger.error('❌ Login error:', error) + return res.status(500).json({ error: 'Login failed', message: 'Internal server error' - }); + }) } -}); +}) // 🚪 管理员登出 router.post('/auth/logout', async (req, res) => { try { - const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken; - + const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken + if (token) { - await redis.deleteSession(token); - logger.success('🚪 Admin logout successful'); + await redis.deleteSession(token) + logger.success('🚪 Admin logout successful') } - res.json({ success: true, message: 'Logout successful' }); + return res.json({ success: true, message: 'Logout successful' }) } catch (error) { - logger.error('❌ Logout error:', error); - res.status(500).json({ + logger.error('❌ Logout error:', error) + return res.status(500).json({ error: 'Logout failed', message: 'Internal server error' - }); + }) } -}); +}) // 🔑 修改账户信息 router.post('/auth/change-password', async (req, res) => { try { - const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken; - + const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken + if (!token) { return res.status(401).json({ error: 'No token provided', message: 'Authentication required' - }); + }) } - const { newUsername, currentPassword, newPassword } = req.body; + const { newUsername, currentPassword, newPassword } = req.body if (!currentPassword || !newPassword) { return res.status(400).json({ error: 'Missing required fields', message: 'Current password and new password are required' - }); + }) } // 验证新密码长度 @@ -160,189 +159,186 @@ router.post('/auth/change-password', async (req, res) => { return res.status(400).json({ error: 'Password too short', message: 'New password must be at least 8 characters long' - }); + }) } // 获取当前会话 - const sessionData = await redis.getSession(token); + const sessionData = await redis.getSession(token) if (!sessionData) { return res.status(401).json({ error: 'Invalid token', message: 'Session expired or invalid' - }); + }) } // 获取当前管理员信息 - const adminData = await redis.getSession('admin_credentials'); + const adminData = await redis.getSession('admin_credentials') if (!adminData) { return res.status(500).json({ error: 'Admin data not found', message: 'Administrator credentials not found' - }); + }) } // 验证当前密码 - const isValidPassword = await bcrypt.compare(currentPassword, adminData.passwordHash); + const isValidPassword = await bcrypt.compare(currentPassword, adminData.passwordHash) if (!isValidPassword) { - logger.security(`🔒 Invalid current password attempt for user: ${sessionData.username}`); + logger.security(`🔒 Invalid current password attempt for user: ${sessionData.username}`) return res.status(401).json({ error: 'Invalid current password', message: 'Current password is incorrect' - }); + }) } // 准备更新的数据 - const updatedUsername = newUsername && newUsername.trim() ? newUsername.trim() : adminData.username; - + const updatedUsername = + newUsername && newUsername.trim() ? newUsername.trim() : adminData.username + // 先更新 init.json(唯一真实数据源) - const initFilePath = path.join(__dirname, '../../data/init.json'); + const initFilePath = path.join(__dirname, '../../data/init.json') if (!fs.existsSync(initFilePath)) { return res.status(500).json({ error: 'Configuration file not found', message: 'init.json file is missing' - }); + }) } - + try { - const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')); + const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')) // const oldData = { ...initData }; // 备份旧数据 - + // 更新 init.json - initData.adminUsername = updatedUsername; - initData.adminPassword = newPassword; // 保存明文密码到init.json - initData.updatedAt = new Date().toISOString(); - + initData.adminUsername = updatedUsername + initData.adminPassword = newPassword // 保存明文密码到init.json + initData.updatedAt = new Date().toISOString() + // 先写入文件(如果失败则不会影响 Redis) - fs.writeFileSync(initFilePath, JSON.stringify(initData, null, 2)); - + fs.writeFileSync(initFilePath, JSON.stringify(initData, null, 2)) + // 文件写入成功后,更新 Redis 缓存 - const saltRounds = 10; - const newPasswordHash = await bcrypt.hash(newPassword, saltRounds); - + const saltRounds = 10 + const newPasswordHash = await bcrypt.hash(newPassword, saltRounds) + const updatedAdminData = { username: updatedUsername, passwordHash: newPasswordHash, createdAt: adminData.createdAt, lastLogin: adminData.lastLogin, updatedAt: new Date().toISOString() - }; - - await redis.setSession('admin_credentials', updatedAdminData); - + } + + await redis.setSession('admin_credentials', updatedAdminData) } catch (fileError) { - logger.error('❌ Failed to update init.json:', fileError); + logger.error('❌ Failed to update init.json:', fileError) return res.status(500).json({ error: 'Update failed', message: 'Failed to update configuration file' - }); + }) } // 清除当前会话(强制用户重新登录) - await redis.deleteSession(token); + await redis.deleteSession(token) - logger.success(`🔐 Admin password changed successfully for user: ${updatedUsername}`); + logger.success(`🔐 Admin password changed successfully for user: ${updatedUsername}`) - res.json({ + return res.json({ success: true, message: 'Password changed successfully. Please login again.', newUsername: updatedUsername - }); - + }) } catch (error) { - logger.error('❌ Change password error:', error); - res.status(500).json({ + logger.error('❌ Change password error:', error) + return res.status(500).json({ error: 'Change password failed', message: 'Internal server error' - }); + }) } -}); +}) // 👤 获取当前用户信息 router.get('/auth/user', async (req, res) => { try { - const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken; - + const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken + if (!token) { return res.status(401).json({ error: 'No token provided', message: 'Authentication required' - }); + }) } // 获取当前会话 - const sessionData = await redis.getSession(token); + const sessionData = await redis.getSession(token) if (!sessionData) { return res.status(401).json({ error: 'Invalid token', message: 'Session expired or invalid' - }); + }) } // 获取管理员信息 - const adminData = await redis.getSession('admin_credentials'); + const adminData = await redis.getSession('admin_credentials') if (!adminData) { return res.status(500).json({ error: 'Admin data not found', message: 'Administrator credentials not found' - }); + }) } - res.json({ + return res.json({ success: true, user: { username: adminData.username, loginTime: sessionData.loginTime, lastActivity: sessionData.lastActivity } - }); - + }) } catch (error) { - logger.error('❌ Get user info error:', error); - res.status(500).json({ + logger.error('❌ Get user info error:', error) + return res.status(500).json({ error: 'Get user info failed', message: 'Internal server error' - }); + }) } -}); +}) // 🔄 刷新token router.post('/auth/refresh', async (req, res) => { try { - const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken; - + const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken + if (!token) { return res.status(401).json({ error: 'No token provided', message: 'Authentication required' - }); + }) } - const sessionData = await redis.getSession(token); - + const sessionData = await redis.getSession(token) + if (!sessionData) { return res.status(401).json({ error: 'Invalid token', message: 'Session expired or invalid' - }); + }) } // 更新最后活动时间 - sessionData.lastActivity = new Date().toISOString(); - await redis.setSession(token, sessionData, config.security.adminSessionTimeout); + sessionData.lastActivity = new Date().toISOString() + await redis.setSession(token, sessionData, config.security.adminSessionTimeout) - res.json({ + return res.json({ success: true, - token: token, + token, expiresIn: config.security.adminSessionTimeout - }); - + }) } catch (error) { - logger.error('❌ Token refresh error:', error); - res.status(500).json({ + logger.error('❌ Token refresh error:', error) + return res.status(500).json({ error: 'Token refresh failed', message: 'Internal server error' - }); + }) } -}); +}) -module.exports = router; \ No newline at end of file +module.exports = router diff --git a/src/services/accountGroupService.js b/src/services/accountGroupService.js index 979bb78d..cfd0c50a 100644 --- a/src/services/accountGroupService.js +++ b/src/services/accountGroupService.js @@ -1,12 +1,12 @@ -const { v4: uuidv4 } = require('uuid'); -const logger = require('../utils/logger'); -const redis = require('../models/redis'); +const { v4: uuidv4 } = require('uuid') +const logger = require('../utils/logger') +const redis = require('../models/redis') class AccountGroupService { constructor() { - this.GROUPS_KEY = 'account_groups'; - this.GROUP_PREFIX = 'account_group:'; - this.GROUP_MEMBERS_PREFIX = 'account_group_members:'; + this.GROUPS_KEY = 'account_groups' + this.GROUP_PREFIX = 'account_group:' + this.GROUP_MEMBERS_PREFIX = 'account_group_members:' } /** @@ -19,22 +19,22 @@ class AccountGroupService { */ async createGroup(groupData) { try { - const { name, platform, description = '' } = groupData; - + const { name, platform, description = '' } = groupData + // 验证必填字段 if (!name || !platform) { - throw new Error('分组名称和平台类型为必填项'); + throw new Error('分组名称和平台类型为必填项') } - + // 验证平台类型 if (!['claude', 'gemini'].includes(platform)) { - throw new Error('平台类型必须是 claude 或 gemini'); + throw new Error('平台类型必须是 claude 或 gemini') } - - const client = redis.getClientSafe(); - const groupId = uuidv4(); - const now = new Date().toISOString(); - + + const client = redis.getClientSafe() + const groupId = uuidv4() + const now = new Date().toISOString() + const group = { id: groupId, name, @@ -42,20 +42,20 @@ class AccountGroupService { description, createdAt: now, updatedAt: now - }; - + } + // 保存分组数据 - await client.hmset(`${this.GROUP_PREFIX}${groupId}`, group); - + await client.hmset(`${this.GROUP_PREFIX}${groupId}`, group) + // 添加到分组集合 - await client.sadd(this.GROUPS_KEY, groupId); - - logger.success(`✅ 创建账户分组成功: ${name} (${platform})`); - - return group; + await client.sadd(this.GROUPS_KEY, groupId) + + logger.success(`✅ 创建账户分组成功: ${name} (${platform})`) + + return group } catch (error) { - logger.error('❌ 创建账户分组失败:', error); - throw error; + logger.error('❌ 创建账户分组失败:', error) + throw error } } @@ -67,46 +67,46 @@ class AccountGroupService { */ async updateGroup(groupId, updates) { try { - const client = redis.getClientSafe(); - const groupKey = `${this.GROUP_PREFIX}${groupId}`; - + const client = redis.getClientSafe() + const groupKey = `${this.GROUP_PREFIX}${groupId}` + // 检查分组是否存在 - const exists = await client.exists(groupKey); + const exists = await client.exists(groupKey) if (!exists) { - throw new Error('分组不存在'); + throw new Error('分组不存在') } - + // 获取现有分组数据 - const existingGroup = await client.hgetall(groupKey); - + const existingGroup = await client.hgetall(groupKey) + // 不允许修改平台类型 if (updates.platform && updates.platform !== existingGroup.platform) { - throw new Error('不能修改分组的平台类型'); + throw new Error('不能修改分组的平台类型') } - + // 准备更新数据 const updateData = { ...updates, updatedAt: new Date().toISOString() - }; - + } + // 移除不允许修改的字段 - delete updateData.id; - delete updateData.platform; - delete updateData.createdAt; - + delete updateData.id + delete updateData.platform + delete updateData.createdAt + // 更新分组 - await client.hmset(groupKey, updateData); - + await client.hmset(groupKey, updateData) + // 返回更新后的完整数据 - const updatedGroup = await client.hgetall(groupKey); - - logger.success(`✅ 更新账户分组成功: ${updatedGroup.name}`); - - return updatedGroup; + const updatedGroup = await client.hgetall(groupKey) + + logger.success(`✅ 更新账户分组成功: ${updatedGroup.name}`) + + return updatedGroup } catch (error) { - logger.error('❌ 更新账户分组失败:', error); - throw error; + logger.error('❌ 更新账户分组失败:', error) + throw error } } @@ -116,37 +116,37 @@ class AccountGroupService { */ async deleteGroup(groupId) { try { - const client = redis.getClientSafe(); - + const client = redis.getClientSafe() + // 检查分组是否存在 - const group = await this.getGroup(groupId); + const group = await this.getGroup(groupId) if (!group) { - throw new Error('分组不存在'); + throw new Error('分组不存在') } - + // 检查分组是否为空 - const members = await this.getGroupMembers(groupId); + const members = await this.getGroupMembers(groupId) if (members.length > 0) { - throw new Error('分组内还有账户,无法删除'); + throw new Error('分组内还有账户,无法删除') } - + // 检查是否有API Key绑定此分组 - const boundApiKeys = await this.getApiKeysUsingGroup(groupId); + const boundApiKeys = await this.getApiKeysUsingGroup(groupId) if (boundApiKeys.length > 0) { - throw new Error('还有API Key使用此分组,无法删除'); + throw new Error('还有API Key使用此分组,无法删除') } - + // 删除分组数据 - await client.del(`${this.GROUP_PREFIX}${groupId}`); - await client.del(`${this.GROUP_MEMBERS_PREFIX}${groupId}`); - + await client.del(`${this.GROUP_PREFIX}${groupId}`) + await client.del(`${this.GROUP_MEMBERS_PREFIX}${groupId}`) + // 从分组集合中移除 - await client.srem(this.GROUPS_KEY, groupId); - - logger.success(`✅ 删除账户分组成功: ${group.name}`); + await client.srem(this.GROUPS_KEY, groupId) + + logger.success(`✅ 删除账户分组成功: ${group.name}`) } catch (error) { - logger.error('❌ 删除账户分组失败:', error); - throw error; + logger.error('❌ 删除账户分组失败:', error) + throw error } } @@ -157,23 +157,23 @@ class AccountGroupService { */ async getGroup(groupId) { try { - const client = redis.getClientSafe(); - const groupData = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`); - + const client = redis.getClientSafe() + const groupData = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`) + if (!groupData || Object.keys(groupData).length === 0) { - return null; + return null } - + // 获取成员数量 - const memberCount = await client.scard(`${this.GROUP_MEMBERS_PREFIX}${groupId}`); - + const memberCount = await client.scard(`${this.GROUP_MEMBERS_PREFIX}${groupId}`) + return { ...groupData, memberCount: memberCount || 0 - }; + } } catch (error) { - logger.error('❌ 获取分组详情失败:', error); - throw error; + logger.error('❌ 获取分组详情失败:', error) + throw error } } @@ -184,27 +184,27 @@ class AccountGroupService { */ async getAllGroups(platform = null) { try { - const client = redis.getClientSafe(); - const groupIds = await client.smembers(this.GROUPS_KEY); - - const groups = []; + const client = redis.getClientSafe() + const groupIds = await client.smembers(this.GROUPS_KEY) + + const groups = [] for (const groupId of groupIds) { - const group = await this.getGroup(groupId); + const group = await this.getGroup(groupId) if (group) { // 如果指定了平台,进行筛选 if (!platform || group.platform === platform) { - groups.push(group); + groups.push(group) } } } - + // 按创建时间倒序排序 - groups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); - - return groups; + groups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + + return groups } catch (error) { - logger.error('❌ 获取分组列表失败:', error); - throw error; + logger.error('❌ 获取分组列表失败:', error) + throw error } } @@ -216,27 +216,28 @@ class AccountGroupService { */ async addAccountToGroup(accountId, groupId, accountPlatform) { try { - const client = redis.getClientSafe(); - + const client = redis.getClientSafe() + // 获取分组信息 - const group = await this.getGroup(groupId); + const group = await this.getGroup(groupId) if (!group) { - throw new Error('分组不存在'); + throw new Error('分组不存在') } - + // 验证平台一致性 (Claude和Claude Console视为同一平台) - const normalizedAccountPlatform = accountPlatform === 'claude-console' ? 'claude' : accountPlatform; + const normalizedAccountPlatform = + accountPlatform === 'claude-console' ? 'claude' : accountPlatform if (normalizedAccountPlatform !== group.platform) { - throw new Error('账户平台与分组平台不匹配'); + throw new Error('账户平台与分组平台不匹配') } - + // 添加到分组成员集合 - await client.sadd(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId); - - logger.success(`✅ 添加账户到分组成功: ${accountId} -> ${group.name}`); + await client.sadd(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) + + logger.success(`✅ 添加账户到分组成功: ${accountId} -> ${group.name}`) } catch (error) { - logger.error('❌ 添加账户到分组失败:', error); - throw error; + logger.error('❌ 添加账户到分组失败:', error) + throw error } } @@ -247,15 +248,15 @@ class AccountGroupService { */ async removeAccountFromGroup(accountId, groupId) { try { - const client = redis.getClientSafe(); - + const client = redis.getClientSafe() + // 从分组成员集合中移除 - await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId); - - logger.success(`✅ 从分组移除账户成功: ${accountId}`); + await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) + + logger.success(`✅ 从分组移除账户成功: ${accountId}`) } catch (error) { - logger.error('❌ 从分组移除账户失败:', error); - throw error; + logger.error('❌ 从分组移除账户失败:', error) + throw error } } @@ -266,12 +267,12 @@ class AccountGroupService { */ async getGroupMembers(groupId) { try { - const client = redis.getClientSafe(); - const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`); - return members || []; + const client = redis.getClientSafe() + const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`) + return members || [] } catch (error) { - logger.error('❌ 获取分组成员失败:', error); - throw error; + logger.error('❌ 获取分组成员失败:', error) + throw error } } @@ -282,11 +283,11 @@ class AccountGroupService { */ async isGroupEmpty(groupId) { try { - const members = await this.getGroupMembers(groupId); - return members.length === 0; + const members = await this.getGroupMembers(groupId) + return members.length === 0 } catch (error) { - logger.error('❌ 检查分组是否为空失败:', error); - throw error; + logger.error('❌ 检查分组是否为空失败:', error) + throw error } } @@ -297,29 +298,30 @@ class AccountGroupService { */ async getApiKeysUsingGroup(groupId) { try { - const client = redis.getClientSafe(); - const groupKey = `group:${groupId}`; - + const client = redis.getClientSafe() + const groupKey = `group:${groupId}` + // 获取所有API Key - const apiKeyIds = await client.smembers('api_keys'); - const boundApiKeys = []; - + const apiKeyIds = await client.smembers('api_keys') + const boundApiKeys = [] + for (const keyId of apiKeyIds) { - const keyData = await client.hgetall(`api_key:${keyId}`); - if (keyData && - (keyData.claudeAccountId === groupKey || - keyData.geminiAccountId === groupKey)) { + const keyData = await client.hgetall(`api_key:${keyId}`) + if ( + keyData && + (keyData.claudeAccountId === groupKey || keyData.geminiAccountId === groupKey) + ) { boundApiKeys.push({ id: keyId, name: keyData.name - }); + }) } } - - return boundApiKeys; + + return boundApiKeys } catch (error) { - logger.error('❌ 获取使用分组的API Key失败:', error); - throw error; + logger.error('❌ 获取使用分组的API Key失败:', error) + throw error } } @@ -330,22 +332,22 @@ class AccountGroupService { */ async getAccountGroup(accountId) { try { - const client = redis.getClientSafe(); - const allGroupIds = await client.smembers(this.GROUPS_KEY); - + const client = redis.getClientSafe() + const allGroupIds = await client.smembers(this.GROUPS_KEY) + for (const groupId of allGroupIds) { - const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId); + const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) if (isMember) { - return await this.getGroup(groupId); + return await this.getGroup(groupId) } } - - return null; + + return null } catch (error) { - logger.error('❌ 获取账户所属分组失败:', error); - throw error; + logger.error('❌ 获取账户所属分组失败:', error) + throw error } } } -module.exports = new AccountGroupService(); \ No newline at end of file +module.exports = new AccountGroupService() diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 930fccc8..2172aac7 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -1,12 +1,12 @@ -const crypto = require('crypto'); -const { v4: uuidv4 } = require('uuid'); -const config = require('../../config/config'); -const redis = require('../models/redis'); -const logger = require('../utils/logger'); +const crypto = require('crypto') +const { v4: uuidv4 } = require('uuid') +const config = require('../../config/config') +const redis = require('../models/redis') +const logger = require('../utils/logger') class ApiKeyService { constructor() { - this.prefix = config.security.apiKeyPrefix; + this.prefix = config.security.apiKeyPrefix } // 🔑 生成新的API Key @@ -30,13 +30,13 @@ class ApiKeyService { allowedClients = [], dailyCostLimit = 0, tags = [] - } = options; + } = options // 生成简单的API Key (64字符十六进制) - const apiKey = `${this.prefix}${this._generateSecretKey()}`; - const keyId = uuidv4(); - const hashedKey = this._hashApiKey(apiKey); - + const apiKey = `${this.prefix}${this._generateSecretKey()}` + const keyId = uuidv4() + const hashedKey = this._hashApiKey(apiKey) + const keyData = { id: keyId, name, @@ -61,13 +61,13 @@ class ApiKeyService { lastUsedAt: '', expiresAt: expiresAt || '', createdBy: 'admin' // 可以根据需要扩展用户系统 - }; + } // 保存API Key数据并建立哈希映射 - await redis.setApiKey(keyId, keyData, hashedKey); - - logger.success(`🔑 Generated new API key: ${name} (${keyId})`); - + await redis.setApiKey(keyId, keyData, hashedKey) + + logger.success(`🔑 Generated new API key: ${name} (${keyId})`) + return { id: keyId, apiKey, // 只在创建时返回完整的key @@ -91,69 +91,69 @@ class ApiKeyService { createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, createdBy: keyData.createdBy - }; + } } - // 🔍 验证API Key + // 🔍 验证API Key async validateApiKey(apiKey) { try { if (!apiKey || !apiKey.startsWith(this.prefix)) { - return { valid: false, error: 'Invalid API key format' }; + return { valid: false, error: 'Invalid API key format' } } // 计算API Key的哈希值 - const hashedKey = this._hashApiKey(apiKey); - + const hashedKey = this._hashApiKey(apiKey) + // 通过哈希值直接查找API Key(性能优化) - const keyData = await redis.findApiKeyByHash(hashedKey); - + const keyData = await redis.findApiKeyByHash(hashedKey) + if (!keyData) { - return { valid: false, error: 'API key not found' }; + return { valid: false, error: 'API key not found' } } // 检查是否激活 if (keyData.isActive !== 'true') { - return { valid: false, error: 'API key is disabled' }; + return { valid: false, error: 'API key is disabled' } } // 检查是否过期 if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { - return { valid: false, error: 'API key has expired' }; + return { valid: false, error: 'API key has expired' } } // 获取使用统计(供返回数据使用) - const usage = await redis.getUsageStats(keyData.id); - + const usage = await redis.getUsageStats(keyData.id) + // 获取当日费用统计 - const dailyCost = await redis.getDailyCost(keyData.id); + const dailyCost = await redis.getDailyCost(keyData.id) // 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时) // 注意:lastUsedAt的更新已移至recordUsage方法中 - logger.api(`🔓 API key validated successfully: ${keyData.id}`); + logger.api(`🔓 API key validated successfully: ${keyData.id}`) // 解析限制模型数据 - let restrictedModels = []; + let restrictedModels = [] try { - restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : []; + restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : [] } catch (e) { - restrictedModels = []; + restrictedModels = [] } // 解析允许的客户端 - let allowedClients = []; + let allowedClients = [] try { - allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : []; + allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [] } catch (e) { - allowedClients = []; + allowedClients = [] } // 解析标签 - let tags = []; + let tags = [] try { - tags = keyData.tags ? JSON.parse(keyData.tags) : []; + tags = keyData.tags ? JSON.parse(keyData.tags) : [] } catch (e) { - tags = []; + tags = [] } return { @@ -173,248 +173,306 @@ class ApiKeyService { rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), enableModelRestriction: keyData.enableModelRestriction === 'true', - restrictedModels: restrictedModels, + restrictedModels, enableClientRestriction: keyData.enableClientRestriction === 'true', - allowedClients: allowedClients, + allowedClients, dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), dailyCost: dailyCost || 0, - tags: tags, + tags, usage } - }; + } } catch (error) { - logger.error('❌ API key validation error:', error); - return { valid: false, error: 'Internal validation error' }; + logger.error('❌ API key validation error:', error) + return { valid: false, error: 'Internal validation error' } } } // 📋 获取所有API Keys async getAllApiKeys() { try { - const apiKeys = await redis.getAllApiKeys(); - const client = redis.getClientSafe(); - + const apiKeys = await redis.getAllApiKeys() + const client = redis.getClientSafe() + // 为每个key添加使用统计和当前并发数 for (const key of apiKeys) { - key.usage = await redis.getUsageStats(key.id); - key.tokenLimit = parseInt(key.tokenLimit); - key.concurrencyLimit = parseInt(key.concurrencyLimit || 0); - key.rateLimitWindow = parseInt(key.rateLimitWindow || 0); - key.rateLimitRequests = parseInt(key.rateLimitRequests || 0); - key.currentConcurrency = await redis.getConcurrency(key.id); - key.isActive = key.isActive === 'true'; - key.enableModelRestriction = key.enableModelRestriction === 'true'; - key.enableClientRestriction = key.enableClientRestriction === 'true'; - key.permissions = key.permissions || 'all'; // 兼容旧数据 - key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0); - key.dailyCost = await redis.getDailyCost(key.id) || 0; - + key.usage = await redis.getUsageStats(key.id) + key.tokenLimit = parseInt(key.tokenLimit) + key.concurrencyLimit = parseInt(key.concurrencyLimit || 0) + key.rateLimitWindow = parseInt(key.rateLimitWindow || 0) + key.rateLimitRequests = parseInt(key.rateLimitRequests || 0) + key.currentConcurrency = await redis.getConcurrency(key.id) + key.isActive = key.isActive === 'true' + key.enableModelRestriction = key.enableModelRestriction === 'true' + key.enableClientRestriction = key.enableClientRestriction === 'true' + key.permissions = key.permissions || 'all' // 兼容旧数据 + key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0) + key.dailyCost = (await redis.getDailyCost(key.id)) || 0 + // 获取当前时间窗口的请求次数和Token使用量 if (key.rateLimitWindow > 0) { - const requestCountKey = `rate_limit:requests:${key.id}`; - const tokenCountKey = `rate_limit:tokens:${key.id}`; - - key.currentWindowRequests = parseInt(await client.get(requestCountKey) || '0'); - key.currentWindowTokens = parseInt(await client.get(tokenCountKey) || '0'); + const requestCountKey = `rate_limit:requests:${key.id}` + const tokenCountKey = `rate_limit:tokens:${key.id}` + + key.currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') + key.currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') } else { - key.currentWindowRequests = 0; - key.currentWindowTokens = 0; + key.currentWindowRequests = 0 + key.currentWindowTokens = 0 } - + try { - key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : []; + key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : [] } catch (e) { - key.restrictedModels = []; + key.restrictedModels = [] } try { - key.allowedClients = key.allowedClients ? JSON.parse(key.allowedClients) : []; + key.allowedClients = key.allowedClients ? JSON.parse(key.allowedClients) : [] } catch (e) { - key.allowedClients = []; + key.allowedClients = [] } try { - key.tags = key.tags ? JSON.parse(key.tags) : []; + key.tags = key.tags ? JSON.parse(key.tags) : [] } catch (e) { - key.tags = []; + key.tags = [] } - delete key.apiKey; // 不返回哈希后的key + delete key.apiKey // 不返回哈希后的key } - return apiKeys; + return apiKeys } catch (error) { - logger.error('❌ Failed to get API keys:', error); - throw error; + logger.error('❌ Failed to get API keys:', error) + throw error } } // 📝 更新API Key async updateApiKey(keyId, updates) { try { - const keyData = await redis.getApiKey(keyId); + const keyData = await redis.getApiKey(keyId) if (!keyData || Object.keys(keyData).length === 0) { - throw new Error('API key not found'); + throw new Error('API key not found') } // 允许更新的字段 - const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'claudeConsoleAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients', 'dailyCostLimit', 'tags']; - const updatedData = { ...keyData }; + const allowedUpdates = [ + 'name', + 'description', + 'tokenLimit', + 'concurrencyLimit', + 'rateLimitWindow', + 'rateLimitRequests', + 'isActive', + 'claudeAccountId', + 'claudeConsoleAccountId', + 'geminiAccountId', + 'permissions', + 'expiresAt', + 'enableModelRestriction', + 'restrictedModels', + 'enableClientRestriction', + 'allowedClients', + 'dailyCostLimit', + 'tags' + ] + const updatedData = { ...keyData } for (const [field, value] of Object.entries(updates)) { if (allowedUpdates.includes(field)) { if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') { // 特殊处理数组字段 - updatedData[field] = JSON.stringify(value || []); + updatedData[field] = JSON.stringify(value || []) } else if (field === 'enableModelRestriction' || field === 'enableClientRestriction') { // 布尔值转字符串 - updatedData[field] = String(value); + updatedData[field] = String(value) } else { - updatedData[field] = (value != null ? value : '').toString(); + updatedData[field] = (value !== null && value !== undefined ? value : '').toString() } } } - updatedData.updatedAt = new Date().toISOString(); - + updatedData.updatedAt = new Date().toISOString() + // 更新时不需要重新建立哈希映射,因为API Key本身没有变化 - await redis.setApiKey(keyId, updatedData); - - logger.success(`📝 Updated API key: ${keyId}`); - - return { success: true }; + await redis.setApiKey(keyId, updatedData) + + logger.success(`📝 Updated API key: ${keyId}`) + + return { success: true } } catch (error) { - logger.error('❌ Failed to update API key:', error); - throw error; + logger.error('❌ Failed to update API key:', error) + throw error } } // 🗑️ 删除API Key async deleteApiKey(keyId) { try { - const result = await redis.deleteApiKey(keyId); - + const result = await redis.deleteApiKey(keyId) + if (result === 0) { - throw new Error('API key not found'); + throw new Error('API key not found') } - - logger.success(`🗑️ Deleted API key: ${keyId}`); - - return { success: true }; + + logger.success(`🗑️ Deleted API key: ${keyId}`) + + return { success: true } } catch (error) { - logger.error('❌ Failed to delete API key:', error); - throw error; + logger.error('❌ Failed to delete API key:', error) + throw error } } // 📊 记录使用情况(支持缓存token和账户级别统计) - async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown', accountId = null) { + async recordUsage( + keyId, + inputTokens = 0, + outputTokens = 0, + cacheCreateTokens = 0, + cacheReadTokens = 0, + model = 'unknown', + accountId = null + ) { try { - const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; - + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + // 计算费用 - const CostCalculator = require('../utils/costCalculator'); - const costInfo = CostCalculator.calculateCost({ - input_tokens: inputTokens, - output_tokens: outputTokens, - cache_creation_input_tokens: cacheCreateTokens, - cache_read_input_tokens: cacheReadTokens - }, model); - + const CostCalculator = require('../utils/costCalculator') + const costInfo = CostCalculator.calculateCost( + { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + }, + model + ) + // 记录API Key级别的使用统计 - await redis.incrementTokenUsage(keyId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); - + await redis.incrementTokenUsage( + keyId, + totalTokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model + ) + // 记录费用统计 if (costInfo.costs.total > 0) { - await redis.incrementDailyCost(keyId, costInfo.costs.total); - logger.database(`💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}`); + await redis.incrementDailyCost(keyId, costInfo.costs.total) + logger.database( + `💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}` + ) } else { - logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`); + logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`) } - + // 获取API Key数据以确定关联的账户 - const keyData = await redis.getApiKey(keyId); + const keyData = await redis.getApiKey(keyId) if (keyData && Object.keys(keyData).length > 0) { // 更新最后使用时间 - keyData.lastUsedAt = new Date().toISOString(); - await redis.setApiKey(keyId, keyData); - + keyData.lastUsedAt = new Date().toISOString() + await redis.setApiKey(keyId, keyData) + // 记录账户级别的使用统计(只统计实际处理请求的账户) if (accountId) { - await redis.incrementAccountUsage(accountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); - logger.database(`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`); + await redis.incrementAccountUsage( + accountId, + totalTokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model + ) + logger.database( + `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` + ) } else { - logger.debug('⚠️ No accountId provided for usage recording, skipping account-level statistics'); + logger.debug( + '⚠️ No accountId provided for usage recording, skipping account-level statistics' + ) } } - - const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`]; - if (cacheCreateTokens > 0) logParts.push(`Cache Create: ${cacheCreateTokens}`); - if (cacheReadTokens > 0) logParts.push(`Cache Read: ${cacheReadTokens}`); - logParts.push(`Total: ${totalTokens} tokens`); - - logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`); + + const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`] + if (cacheCreateTokens > 0) { + logParts.push(`Cache Create: ${cacheCreateTokens}`) + } + if (cacheReadTokens > 0) { + logParts.push(`Cache Read: ${cacheReadTokens}`) + } + logParts.push(`Total: ${totalTokens} tokens`) + + logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`) } catch (error) { - logger.error('❌ Failed to record usage:', error); + logger.error('❌ Failed to record usage:', error) } } // 🔐 生成密钥 _generateSecretKey() { - return crypto.randomBytes(32).toString('hex'); + return crypto.randomBytes(32).toString('hex') } // 🔒 哈希API Key _hashApiKey(apiKey) { - return crypto.createHash('sha256').update(apiKey + config.security.encryptionKey).digest('hex'); + return crypto + .createHash('sha256') + .update(apiKey + config.security.encryptionKey) + .digest('hex') } // 📈 获取使用统计 async getUsageStats(keyId) { - return await redis.getUsageStats(keyId); + return await redis.getUsageStats(keyId) } // 📊 获取账户使用统计 async getAccountUsageStats(accountId) { - return await redis.getAccountUsageStats(accountId); + return await redis.getAccountUsageStats(accountId) } // 📈 获取所有账户使用统计 async getAllAccountsUsageStats() { - return await redis.getAllAccountsUsageStats(); + return await redis.getAllAccountsUsageStats() } - // 🧹 清理过期的API Keys async cleanupExpiredKeys() { try { - const apiKeys = await redis.getAllApiKeys(); - const now = new Date(); - let cleanedCount = 0; + const apiKeys = await redis.getAllApiKeys() + const now = new Date() + let cleanedCount = 0 for (const key of apiKeys) { // 检查是否已过期且仍处于激活状态 if (key.expiresAt && new Date(key.expiresAt) < now && key.isActive === 'true') { // 将过期的 API Key 标记为禁用状态,而不是直接删除 - await this.updateApiKey(key.id, { isActive: false }); - logger.info(`🔒 API Key ${key.id} (${key.name}) has expired and been disabled`); - cleanedCount++; + await this.updateApiKey(key.id, { isActive: false }) + logger.info(`🔒 API Key ${key.id} (${key.name}) has expired and been disabled`) + cleanedCount++ } } if (cleanedCount > 0) { - logger.success(`🧹 Disabled ${cleanedCount} expired API keys`); + logger.success(`🧹 Disabled ${cleanedCount} expired API keys`) } - return cleanedCount; + return cleanedCount } catch (error) { - logger.error('❌ Failed to cleanup expired keys:', error); - return 0; + logger.error('❌ Failed to cleanup expired keys:', error) + return 0 } } } // 导出实例和单独的方法 -const apiKeyService = new ApiKeyService(); +const apiKeyService = new ApiKeyService() // 为了方便其他服务调用,导出 recordUsage 方法 -apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService); +apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService) -module.exports = apiKeyService; \ No newline at end of file +module.exports = apiKeyService diff --git a/src/services/bedrockAccountService.js b/src/services/bedrockAccountService.js index e919e460..a4fdbde3 100644 --- a/src/services/bedrockAccountService.js +++ b/src/services/bedrockAccountService.js @@ -1,15 +1,15 @@ -const { v4: uuidv4 } = require('uuid'); -const crypto = require('crypto'); -const redis = require('../models/redis'); -const logger = require('../utils/logger'); -const config = require('../../config/config'); -const bedrockRelayService = require('./bedrockRelayService'); +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const config = require('../../config/config') +const bedrockRelayService = require('./bedrockRelayService') class BedrockAccountService { constructor() { // 加密相关常量 - this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'; - this.ENCRYPTION_SALT = 'salt'; + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'salt' } // 🏢 创建Bedrock账户 @@ -25,11 +25,11 @@ class BedrockAccountService { priority = 50, // 调度优先级 (1-100,数字越小优先级越高) schedulable = true, // 是否可被调度 credentialType = 'default' // 'default', 'access_key', 'bearer_token' - } = options; + } = options - const accountId = uuidv4(); + const accountId = uuidv4() - let accountData = { + const accountData = { id: accountId, name, description, @@ -43,17 +43,17 @@ class BedrockAccountService { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), type: 'bedrock' // 标识这是Bedrock账户 - }; + } // 加密存储AWS凭证 if (awsCredentials) { - accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials); + accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials) } - const client = redis.getClientSafe(); - await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData)); + const client = redis.getClientSafe() + await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData)) - logger.info(`✅ 创建Bedrock账户成功 - ID: ${accountId}, 名称: ${name}, 区域: ${region}`); + logger.info(`✅ 创建Bedrock账户成功 - ID: ${accountId}, 名称: ${name}, 区域: ${region}`) return { success: true, @@ -71,48 +71,48 @@ class BedrockAccountService { createdAt: accountData.createdAt, type: 'bedrock' } - }; + } } // 🔍 获取账户信息 async getAccount(accountId) { try { - const client = redis.getClientSafe(); - const accountData = await client.get(`bedrock_account:${accountId}`); + const client = redis.getClientSafe() + const accountData = await client.get(`bedrock_account:${accountId}`) if (!accountData) { - return { success: false, error: 'Account not found' }; + return { success: false, error: 'Account not found' } } - const account = JSON.parse(accountData); + const account = JSON.parse(accountData) // 解密AWS凭证用于内部使用 if (account.awsCredentials) { - account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials); + account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials) } - logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`); + logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`) return { success: true, data: account - }; + } } catch (error) { - logger.error(`❌ 获取Bedrock账户失败 - ID: ${accountId}`, error); - return { success: false, error: error.message }; + logger.error(`❌ 获取Bedrock账户失败 - ID: ${accountId}`, error) + return { success: false, error: error.message } } } // 📋 获取所有账户列表 async getAllAccounts() { try { - const client = redis.getClientSafe(); - const keys = await client.keys('bedrock_account:*'); - const accounts = []; + const client = redis.getClientSafe() + const keys = await client.keys('bedrock_account:*') + const accounts = [] for (const key of keys) { - const accountData = await client.get(key); + const accountData = await client.get(key) if (accountData) { - const account = JSON.parse(accountData); + const account = JSON.parse(accountData) // 返回给前端时,不包含敏感信息,只显示掩码 accounts.push({ @@ -130,25 +130,27 @@ class BedrockAccountService { updatedAt: account.updatedAt, type: 'bedrock', hasCredentials: !!account.awsCredentials - }); + }) } } // 按优先级和名称排序 accounts.sort((a, b) => { - if (a.priority !== b.priority) return a.priority - b.priority; - return a.name.localeCompare(b.name); - }); + if (a.priority !== b.priority) { + return a.priority - b.priority + } + return a.name.localeCompare(b.name) + }) - logger.debug(`📋 获取所有Bedrock账户 - 共 ${accounts.length} 个`); + logger.debug(`📋 获取所有Bedrock账户 - 共 ${accounts.length} 个`) return { success: true, data: accounts - }; + } } catch (error) { - logger.error('❌ 获取Bedrock账户列表失败', error); - return { success: false, error: error.message }; + logger.error('❌ 获取Bedrock账户列表失败', error) + return { success: false, error: error.message } } } @@ -156,44 +158,62 @@ class BedrockAccountService { async updateAccount(accountId, updates = {}) { try { // 获取原始账户数据(不解密凭证) - const client = redis.getClientSafe(); - const accountData = await client.get(`bedrock_account:${accountId}`); + const client = redis.getClientSafe() + const accountData = await client.get(`bedrock_account:${accountId}`) if (!accountData) { - return { success: false, error: 'Account not found' }; + return { success: false, error: 'Account not found' } } - const account = JSON.parse(accountData); + const account = JSON.parse(accountData) // 更新字段 - if (updates.name !== undefined) account.name = updates.name; - if (updates.description !== undefined) account.description = updates.description; - if (updates.region !== undefined) account.region = updates.region; - if (updates.defaultModel !== undefined) account.defaultModel = updates.defaultModel; - if (updates.isActive !== undefined) account.isActive = updates.isActive; - if (updates.accountType !== undefined) account.accountType = updates.accountType; - if (updates.priority !== undefined) account.priority = updates.priority; - if (updates.schedulable !== undefined) account.schedulable = updates.schedulable; - if (updates.credentialType !== undefined) account.credentialType = updates.credentialType; + if (updates.name !== undefined) { + account.name = updates.name + } + if (updates.description !== undefined) { + account.description = updates.description + } + if (updates.region !== undefined) { + account.region = updates.region + } + if (updates.defaultModel !== undefined) { + account.defaultModel = updates.defaultModel + } + if (updates.isActive !== undefined) { + account.isActive = updates.isActive + } + if (updates.accountType !== undefined) { + account.accountType = updates.accountType + } + if (updates.priority !== undefined) { + account.priority = updates.priority + } + if (updates.schedulable !== undefined) { + account.schedulable = updates.schedulable + } + if (updates.credentialType !== undefined) { + account.credentialType = updates.credentialType + } // 更新AWS凭证 if (updates.awsCredentials !== undefined) { if (updates.awsCredentials) { - account.awsCredentials = this._encryptAwsCredentials(updates.awsCredentials); + account.awsCredentials = this._encryptAwsCredentials(updates.awsCredentials) } else { - delete account.awsCredentials; + delete account.awsCredentials } } else if (account.awsCredentials && account.awsCredentials.accessKeyId) { // 如果没有提供新凭证但现有凭证是明文格式,重新加密 - const plainCredentials = account.awsCredentials; - account.awsCredentials = this._encryptAwsCredentials(plainCredentials); - logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`); + const plainCredentials = account.awsCredentials + account.awsCredentials = this._encryptAwsCredentials(plainCredentials) + logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`) } - account.updatedAt = new Date().toISOString(); + account.updatedAt = new Date().toISOString() - await client.set(`bedrock_account:${accountId}`, JSON.stringify(account)); + await client.set(`bedrock_account:${accountId}`, JSON.stringify(account)) - logger.info(`✅ 更新Bedrock账户成功 - ID: ${accountId}, 名称: ${account.name}`); + logger.info(`✅ 更新Bedrock账户成功 - ID: ${accountId}, 名称: ${account.name}`) return { success: true, @@ -211,87 +231,87 @@ class BedrockAccountService { updatedAt: account.updatedAt, type: 'bedrock' } - }; + } } catch (error) { - logger.error(`❌ 更新Bedrock账户失败 - ID: ${accountId}`, error); - return { success: false, error: error.message }; + logger.error(`❌ 更新Bedrock账户失败 - ID: ${accountId}`, error) + return { success: false, error: error.message } } } // 🗑️ 删除账户 async deleteAccount(accountId) { try { - const accountResult = await this.getAccount(accountId); + const accountResult = await this.getAccount(accountId) if (!accountResult.success) { - return accountResult; + return accountResult } - const client = redis.getClientSafe(); - await client.del(`bedrock_account:${accountId}`); + const client = redis.getClientSafe() + await client.del(`bedrock_account:${accountId}`) - logger.info(`✅ 删除Bedrock账户成功 - ID: ${accountId}`); + logger.info(`✅ 删除Bedrock账户成功 - ID: ${accountId}`) - return { success: true }; + return { success: true } } catch (error) { - logger.error(`❌ 删除Bedrock账户失败 - ID: ${accountId}`, error); - return { success: false, error: error.message }; + logger.error(`❌ 删除Bedrock账户失败 - ID: ${accountId}`, error) + return { success: false, error: error.message } } } // 🎯 选择可用的Bedrock账户 (用于请求转发) async selectAvailableAccount() { try { - const accountsResult = await this.getAllAccounts(); + const accountsResult = await this.getAllAccounts() if (!accountsResult.success) { - return { success: false, error: 'Failed to get accounts' }; + return { success: false, error: 'Failed to get accounts' } } - const availableAccounts = accountsResult.data.filter(account => - account.isActive && account.schedulable - ); + const availableAccounts = accountsResult.data.filter( + (account) => account.isActive && account.schedulable + ) if (availableAccounts.length === 0) { - return { success: false, error: 'No available Bedrock accounts' }; + return { success: false, error: 'No available Bedrock accounts' } } // 简单的轮询选择策略 - 选择优先级最高的账户 - const selectedAccount = availableAccounts[0]; + const selectedAccount = availableAccounts[0] // 获取完整账户信息(包含解密的凭证) - const fullAccountResult = await this.getAccount(selectedAccount.id); + const fullAccountResult = await this.getAccount(selectedAccount.id) if (!fullAccountResult.success) { - return { success: false, error: 'Failed to get selected account details' }; + return { success: false, error: 'Failed to get selected account details' } } - logger.debug(`🎯 选择Bedrock账户 - ID: ${selectedAccount.id}, 名称: ${selectedAccount.name}`); + logger.debug(`🎯 选择Bedrock账户 - ID: ${selectedAccount.id}, 名称: ${selectedAccount.name}`) return { success: true, data: fullAccountResult.data - }; + } } catch (error) { - logger.error('❌ 选择Bedrock账户失败', error); - return { success: false, error: error.message }; + logger.error('❌ 选择Bedrock账户失败', error) + return { success: false, error: error.message } } } // 🧪 测试账户连接 async testAccount(accountId) { try { - const accountResult = await this.getAccount(accountId); + const accountResult = await this.getAccount(accountId) if (!accountResult.success) { - return accountResult; + return accountResult } - const account = accountResult.data; + const account = accountResult.data - logger.info(`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}`); + logger.info(`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}`) // 尝试获取模型列表来测试连接 - const models = await bedrockRelayService.getAvailableModels(account); + const models = await bedrockRelayService.getAvailableModels(account) if (models && models.length > 0) { - logger.info(`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型`); + logger.info(`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型`) return { success: true, data: { @@ -300,40 +320,40 @@ class BedrockAccountService { region: account.region, credentialType: account.credentialType } - }; + } } else { return { success: false, error: 'Unable to retrieve models from Bedrock' - }; + } } } catch (error) { - logger.error(`❌ 测试Bedrock账户失败 - ID: ${accountId}`, error); + logger.error(`❌ 测试Bedrock账户失败 - ID: ${accountId}`, error) return { success: false, error: error.message - }; + } } } // 🔐 加密AWS凭证 _encryptAwsCredentials(credentials) { try { - const key = crypto.createHash('sha256').update(config.security.encryptionKey).digest(); - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv); + const key = crypto.createHash('sha256').update(config.security.encryptionKey).digest() + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) - const credentialsString = JSON.stringify(credentials); - let encrypted = cipher.update(credentialsString, 'utf8', 'hex'); - encrypted += cipher.final('hex'); + const credentialsString = JSON.stringify(credentials) + let encrypted = cipher.update(credentialsString, 'utf8', 'hex') + encrypted += cipher.final('hex') return { - encrypted: encrypted, + encrypted, iv: iv.toString('hex') - }; + } } catch (error) { - logger.error('❌ AWS凭证加密失败', error); - throw new Error('Credentials encryption failed'); + logger.error('❌ AWS凭证加密失败', error) + throw new Error('Credentials encryption failed') } } @@ -342,70 +362,71 @@ class BedrockAccountService { try { // 检查数据格式 if (!encryptedData || typeof encryptedData !== 'object') { - logger.error('❌ 无效的加密数据格式:', encryptedData); - throw new Error('Invalid encrypted data format'); + logger.error('❌ 无效的加密数据格式:', encryptedData) + throw new Error('Invalid encrypted data format') } // 检查是否为加密格式 (有 encrypted 和 iv 字段) if (encryptedData.encrypted && encryptedData.iv) { // 加密数据 - 进行解密 - const key = crypto.createHash('sha256').update(config.security.encryptionKey).digest(); - const iv = Buffer.from(encryptedData.iv, 'hex'); - const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv); + const key = crypto.createHash('sha256').update(config.security.encryptionKey).digest() + const iv = Buffer.from(encryptedData.iv, 'hex') + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) - let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); + let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') - return JSON.parse(decrypted); + return JSON.parse(decrypted) } else if (encryptedData.accessKeyId) { // 纯文本数据 - 直接返回 (向后兼容) - logger.warn('⚠️ 发现未加密的AWS凭证,建议更新账户以启用加密'); - return encryptedData; + logger.warn('⚠️ 发现未加密的AWS凭证,建议更新账户以启用加密') + return encryptedData } else { // 既不是加密格式也不是有效的凭证格式 logger.error('❌ 缺少加密数据字段:', { hasEncrypted: !!encryptedData.encrypted, hasIv: !!encryptedData.iv, hasAccessKeyId: !!encryptedData.accessKeyId - }); - throw new Error('Missing encrypted data fields or valid credentials'); + }) + throw new Error('Missing encrypted data fields or valid credentials') } } catch (error) { - logger.error('❌ AWS凭证解密失败', error); - throw new Error('Credentials decryption failed'); + logger.error('❌ AWS凭证解密失败', error) + throw new Error('Credentials decryption failed') } } // 🔍 获取账户统计信息 async getAccountStats() { try { - const accountsResult = await this.getAllAccounts(); + const accountsResult = await this.getAllAccounts() if (!accountsResult.success) { - return { success: false, error: accountsResult.error }; + return { success: false, error: accountsResult.error } } - const accounts = accountsResult.data; + const accounts = accountsResult.data const stats = { total: accounts.length, - active: accounts.filter(acc => acc.isActive).length, - inactive: accounts.filter(acc => !acc.isActive).length, - schedulable: accounts.filter(acc => acc.schedulable).length, + active: accounts.filter((acc) => acc.isActive).length, + inactive: accounts.filter((acc) => !acc.isActive).length, + schedulable: accounts.filter((acc) => acc.schedulable).length, byRegion: {}, byCredentialType: {} - }; + } // 按区域统计 - accounts.forEach(acc => { - stats.byRegion[acc.region] = (stats.byRegion[acc.region] || 0) + 1; - stats.byCredentialType[acc.credentialType] = (stats.byCredentialType[acc.credentialType] || 0) + 1; - }); + accounts.forEach((acc) => { + stats.byRegion[acc.region] = (stats.byRegion[acc.region] || 0) + 1 + stats.byCredentialType[acc.credentialType] = + (stats.byCredentialType[acc.credentialType] || 0) + 1 + }) - return { success: true, data: stats }; + return { success: true, data: stats } } catch (error) { - logger.error('❌ 获取Bedrock账户统计失败', error); - return { success: false, error: error.message }; + logger.error('❌ 获取Bedrock账户统计失败', error) + return { success: false, error: error.message } } } } -module.exports = new BedrockAccountService(); \ No newline at end of file +module.exports = new BedrockAccountService() diff --git a/src/services/bedrockRelayService.js b/src/services/bedrockRelayService.js index 3df53e10..e27dfd5c 100644 --- a/src/services/bedrockRelayService.js +++ b/src/services/bedrockRelayService.js @@ -1,38 +1,44 @@ -const { BedrockRuntimeClient, InvokeModelCommand, InvokeModelWithResponseStreamCommand } = require('@aws-sdk/client-bedrock-runtime'); -const { fromEnv } = require('@aws-sdk/credential-providers'); -const logger = require('../utils/logger'); -const config = require('../../config/config'); +const { + BedrockRuntimeClient, + InvokeModelCommand, + InvokeModelWithResponseStreamCommand +} = require('@aws-sdk/client-bedrock-runtime') +const { fromEnv } = require('@aws-sdk/credential-providers') +const logger = require('../utils/logger') +const config = require('../../config/config') class BedrockRelayService { constructor() { - this.defaultRegion = process.env.AWS_REGION || config.bedrock?.defaultRegion || 'us-east-1'; - this.smallFastModelRegion = process.env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION || this.defaultRegion; + this.defaultRegion = process.env.AWS_REGION || config.bedrock?.defaultRegion || 'us-east-1' + this.smallFastModelRegion = + process.env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION || this.defaultRegion // 默认模型配置 - this.defaultModel = process.env.ANTHROPIC_MODEL || 'us.anthropic.claude-sonnet-4-20250514-v1:0'; - this.defaultSmallModel = process.env.ANTHROPIC_SMALL_FAST_MODEL || 'us.anthropic.claude-3-5-haiku-20241022-v1:0'; + this.defaultModel = process.env.ANTHROPIC_MODEL || 'us.anthropic.claude-sonnet-4-20250514-v1:0' + this.defaultSmallModel = + process.env.ANTHROPIC_SMALL_FAST_MODEL || 'us.anthropic.claude-3-5-haiku-20241022-v1:0' // Token配置 - this.maxOutputTokens = parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) || 4096; - this.maxThinkingTokens = parseInt(process.env.MAX_THINKING_TOKENS) || 1024; - this.enablePromptCaching = process.env.DISABLE_PROMPT_CACHING !== '1'; + this.maxOutputTokens = parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) || 4096 + this.maxThinkingTokens = parseInt(process.env.MAX_THINKING_TOKENS) || 1024 + this.enablePromptCaching = process.env.DISABLE_PROMPT_CACHING !== '1' // 创建Bedrock客户端 - this.clients = new Map(); // 缓存不同区域的客户端 + this.clients = new Map() // 缓存不同区域的客户端 } // 获取或创建Bedrock客户端 _getBedrockClient(region = null, bedrockAccount = null) { - const targetRegion = region || this.defaultRegion; - const clientKey = `${targetRegion}-${bedrockAccount?.id || 'default'}`; + const targetRegion = region || this.defaultRegion + const clientKey = `${targetRegion}-${bedrockAccount?.id || 'default'}` if (this.clients.has(clientKey)) { - return this.clients.get(clientKey); + return this.clients.get(clientKey) } const clientConfig = { region: targetRegion - }; + } // 如果账户配置了特定的AWS凭证,使用它们 if (bedrockAccount?.awsCredentials) { @@ -40,51 +46,55 @@ class BedrockRelayService { accessKeyId: bedrockAccount.awsCredentials.accessKeyId, secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey, sessionToken: bedrockAccount.awsCredentials.sessionToken - }; + } } else { // 检查是否有环境变量凭证 if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { - clientConfig.credentials = fromEnv(); + clientConfig.credentials = fromEnv() } else { - throw new Error('AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'); + throw new Error( + 'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY' + ) } } - const client = new BedrockRuntimeClient(clientConfig); - this.clients.set(clientKey, client); + const client = new BedrockRuntimeClient(clientConfig) + this.clients.set(clientKey, client) - logger.debug(`🔧 Created Bedrock client for region: ${targetRegion}, account: ${bedrockAccount?.name || 'default'}`); - return client; + logger.debug( + `🔧 Created Bedrock client for region: ${targetRegion}, account: ${bedrockAccount?.name || 'default'}` + ) + return client } // 处理非流式请求 async handleNonStreamRequest(requestBody, bedrockAccount = null) { try { - const modelId = this._selectModel(requestBody, bedrockAccount); - const region = this._selectRegion(modelId, bedrockAccount); - const client = this._getBedrockClient(region, bedrockAccount); + const modelId = this._selectModel(requestBody, bedrockAccount) + const region = this._selectRegion(modelId, bedrockAccount) + const client = this._getBedrockClient(region, bedrockAccount) // 转换请求格式为Bedrock格式 - const bedrockPayload = this._convertToBedrockFormat(requestBody); + const bedrockPayload = this._convertToBedrockFormat(requestBody) const command = new InvokeModelCommand({ - modelId: modelId, + modelId, body: JSON.stringify(bedrockPayload), contentType: 'application/json', accept: 'application/json' - }); + }) - logger.debug(`🚀 Bedrock非流式请求 - 模型: ${modelId}, 区域: ${region}`); + logger.debug(`🚀 Bedrock非流式请求 - 模型: ${modelId}, 区域: ${region}`) - const startTime = Date.now(); - const response = await client.send(command); - const duration = Date.now() - startTime; + const startTime = Date.now() + const response = await client.send(command) + const duration = Date.now() - startTime // 解析响应 - const responseBody = JSON.parse(new TextDecoder().decode(response.body)); - const claudeResponse = this._convertFromBedrockFormat(responseBody); + const responseBody = JSON.parse(new TextDecoder().decode(response.body)) + const claudeResponse = this._convertFromBedrockFormat(responseBody) - logger.info(`✅ Bedrock请求完成 - 模型: ${modelId}, 耗时: ${duration}ms`); + logger.info(`✅ Bedrock请求完成 - 模型: ${modelId}, 耗时: ${duration}ms`) return { success: true, @@ -92,127 +102,129 @@ class BedrockRelayService { usage: claudeResponse.usage, model: modelId, duration - }; - + } } catch (error) { - logger.error('❌ Bedrock非流式请求失败:', error); - throw this._handleBedrockError(error); + logger.error('❌ Bedrock非流式请求失败:', error) + throw this._handleBedrockError(error) } } // 处理流式请求 async handleStreamRequest(requestBody, bedrockAccount = null, res) { try { - const modelId = this._selectModel(requestBody, bedrockAccount); - const region = this._selectRegion(modelId, bedrockAccount); - const client = this._getBedrockClient(region, bedrockAccount); + const modelId = this._selectModel(requestBody, bedrockAccount) + const region = this._selectRegion(modelId, bedrockAccount) + const client = this._getBedrockClient(region, bedrockAccount) // 转换请求格式为Bedrock格式 - const bedrockPayload = this._convertToBedrockFormat(requestBody); + const bedrockPayload = this._convertToBedrockFormat(requestBody) const command = new InvokeModelWithResponseStreamCommand({ - modelId: modelId, + modelId, body: JSON.stringify(bedrockPayload), contentType: 'application/json', accept: 'application/json' - }); + }) - logger.debug(`🌊 Bedrock流式请求 - 模型: ${modelId}, 区域: ${region}`); + logger.debug(`🌊 Bedrock流式请求 - 模型: ${modelId}, 区域: ${region}`) - const startTime = Date.now(); - const response = await client.send(command); + const startTime = Date.now() + const response = await client.send(command) // 设置SSE响应头 res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', + Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' - }); + }) - let totalUsage = null; - let isFirstChunk = true; + let totalUsage = null + let isFirstChunk = true // 处理流式响应 for await (const chunk of response.body) { if (chunk.chunk) { - const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes)); - const claudeEvent = this._convertBedrockStreamToClaudeFormat(chunkData, isFirstChunk); + const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes)) + const claudeEvent = this._convertBedrockStreamToClaudeFormat(chunkData, isFirstChunk) if (claudeEvent) { // 发送SSE事件 - res.write(`event: ${claudeEvent.type}\n`); - res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`); + res.write(`event: ${claudeEvent.type}\n`) + res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`) // 提取使用统计 if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) { - totalUsage = claudeEvent.data.usage; + totalUsage = claudeEvent.data.usage } - isFirstChunk = false; + isFirstChunk = false } } } - const duration = Date.now() - startTime; - logger.info(`✅ Bedrock流式请求完成 - 模型: ${modelId}, 耗时: ${duration}ms`); + const duration = Date.now() - startTime + logger.info(`✅ Bedrock流式请求完成 - 模型: ${modelId}, 耗时: ${duration}ms`) // 发送结束事件 - res.write('event: done\n'); - res.write('data: [DONE]\n\n'); - res.end(); + res.write('event: done\n') + res.write('data: [DONE]\n\n') + res.end() return { success: true, usage: totalUsage, model: modelId, duration - }; - + } } catch (error) { - logger.error('❌ Bedrock流式请求失败:', error); + logger.error('❌ Bedrock流式请求失败:', error) // 发送错误事件 if (!res.headersSent) { - res.writeHead(500, { 'Content-Type': 'application/json' }); + res.writeHead(500, { 'Content-Type': 'application/json' }) } - res.write('event: error\n'); - res.write(`data: ${JSON.stringify({ error: this._handleBedrockError(error).message })}\n\n`); - res.end(); + res.write('event: error\n') + res.write(`data: ${JSON.stringify({ error: this._handleBedrockError(error).message })}\n\n`) + res.end() - throw this._handleBedrockError(error); + throw this._handleBedrockError(error) } } // 选择使用的模型 _selectModel(requestBody, bedrockAccount) { - let selectedModel; - + let selectedModel + // 优先使用账户配置的模型 if (bedrockAccount?.defaultModel) { - selectedModel = bedrockAccount.defaultModel; - logger.info(`🎯 使用账户配置的模型: ${selectedModel}`, { metadata: { source: 'account', accountId: bedrockAccount.id } }); + selectedModel = bedrockAccount.defaultModel + logger.info(`🎯 使用账户配置的模型: ${selectedModel}`, { + metadata: { source: 'account', accountId: bedrockAccount.id } + }) } // 检查请求中指定的模型 else if (requestBody.model) { - selectedModel = requestBody.model; - logger.info(`🎯 使用请求指定的模型: ${selectedModel}`, { metadata: { source: 'request' } }); + selectedModel = requestBody.model + logger.info(`🎯 使用请求指定的模型: ${selectedModel}`, { metadata: { source: 'request' } }) } // 使用默认模型 else { - selectedModel = this.defaultModel; - logger.info(`🎯 使用系统默认模型: ${selectedModel}`, { metadata: { source: 'default' } }); + selectedModel = this.defaultModel + logger.info(`🎯 使用系统默认模型: ${selectedModel}`, { metadata: { source: 'default' } }) } // 如果是标准Claude模型名,需要映射为Bedrock格式 - const bedrockModel = this._mapToBedrockModel(selectedModel); + const bedrockModel = this._mapToBedrockModel(selectedModel) if (bedrockModel !== selectedModel) { - logger.info(`🔄 模型映射: ${selectedModel} → ${bedrockModel}`, { metadata: { originalModel: selectedModel, bedrockModel } }); + logger.info(`🔄 模型映射: ${selectedModel} → ${bedrockModel}`, { + metadata: { originalModel: selectedModel, bedrockModel } + }) } - return bedrockModel; + return bedrockModel } // 将标准Claude模型名映射为Bedrock格式 @@ -222,63 +234,65 @@ class BedrockRelayService { // Claude Sonnet 4 'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0', 'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0', - + // Claude Opus 4.1 'claude-opus-4': 'us.anthropic.claude-opus-4-1-20250805-v1:0', 'claude-opus-4-1': 'us.anthropic.claude-opus-4-1-20250805-v1:0', 'claude-opus-4-1-20250805': 'us.anthropic.claude-opus-4-1-20250805-v1:0', - + // Claude 3.7 Sonnet 'claude-3-7-sonnet': 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', 'claude-3-7-sonnet-20250219': 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', - + // Claude 3.5 Sonnet v2 'claude-3-5-sonnet': 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', 'claude-3-5-sonnet-20241022': 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', - + // Claude 3.5 Haiku 'claude-3-5-haiku': 'us.anthropic.claude-3-5-haiku-20241022-v1:0', 'claude-3-5-haiku-20241022': 'us.anthropic.claude-3-5-haiku-20241022-v1:0', - + // Claude 3 Sonnet 'claude-3-sonnet': 'us.anthropic.claude-3-sonnet-20240229-v1:0', 'claude-3-sonnet-20240229': 'us.anthropic.claude-3-sonnet-20240229-v1:0', - + // Claude 3 Haiku 'claude-3-haiku': 'us.anthropic.claude-3-haiku-20240307-v1:0', 'claude-3-haiku-20240307': 'us.anthropic.claude-3-haiku-20240307-v1:0' - }; + } // 如果已经是Bedrock格式,直接返回 // Bedrock模型格式:{region}.anthropic.{model-name} 或 anthropic.{model-name} if (modelName.includes('.anthropic.') || modelName.startsWith('anthropic.')) { - return modelName; + return modelName } // 查找映射 - const mappedModel = modelMapping[modelName]; + const mappedModel = modelMapping[modelName] if (mappedModel) { - return mappedModel; + return mappedModel } // 如果没有找到映射,返回原始模型名(可能会导致错误,但保持向后兼容) - logger.warn(`⚠️ 未找到模型映射: ${modelName},使用原始模型名`, { metadata: { originalModel: modelName } }); - return modelName; + logger.warn(`⚠️ 未找到模型映射: ${modelName},使用原始模型名`, { + metadata: { originalModel: modelName } + }) + return modelName } // 选择使用的区域 _selectRegion(modelId, bedrockAccount) { // 优先使用账户配置的区域 if (bedrockAccount?.region) { - return bedrockAccount.region; + return bedrockAccount.region } // 对于小模型,使用专门的区域配置 if (modelId.includes('haiku')) { - return this.smallFastModelRegion; + return this.smallFastModelRegion } - return this.defaultRegion; + return this.defaultRegion } // 转换Claude格式请求到Bedrock格式 @@ -287,40 +301,40 @@ class BedrockRelayService { anthropic_version: 'bedrock-2023-05-31', max_tokens: Math.min(requestBody.max_tokens || this.maxOutputTokens, this.maxOutputTokens), messages: requestBody.messages || [] - }; + } // 添加系统提示词 if (requestBody.system) { - bedrockPayload.system = requestBody.system; + bedrockPayload.system = requestBody.system } // 添加其他参数 if (requestBody.temperature !== undefined) { - bedrockPayload.temperature = requestBody.temperature; + bedrockPayload.temperature = requestBody.temperature } if (requestBody.top_p !== undefined) { - bedrockPayload.top_p = requestBody.top_p; + bedrockPayload.top_p = requestBody.top_p } if (requestBody.top_k !== undefined) { - bedrockPayload.top_k = requestBody.top_k; + bedrockPayload.top_k = requestBody.top_k } if (requestBody.stop_sequences) { - bedrockPayload.stop_sequences = requestBody.stop_sequences; + bedrockPayload.stop_sequences = requestBody.stop_sequences } // 工具调用支持 if (requestBody.tools) { - bedrockPayload.tools = requestBody.tools; + bedrockPayload.tools = requestBody.tools } if (requestBody.tool_choice) { - bedrockPayload.tool_choice = requestBody.tool_choice; + bedrockPayload.tool_choice = requestBody.tool_choice } - return bedrockPayload; + return bedrockPayload } // 转换Bedrock响应到Claude格式 @@ -337,7 +351,7 @@ class BedrockRelayService { input_tokens: 0, output_tokens: 0 } - }; + } } // 转换Bedrock流事件到Claude SSE格式 @@ -355,7 +369,7 @@ class BedrockRelayService { stop_sequence: null, usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 } } - }; + } } if (bedrockChunk.type === 'content_block_delta') { @@ -365,7 +379,7 @@ class BedrockRelayService { index: bedrockChunk.index || 0, delta: bedrockChunk.delta || {} } - }; + } } if (bedrockChunk.type === 'message_delta') { @@ -375,7 +389,7 @@ class BedrockRelayService { delta: bedrockChunk.delta || {}, usage: bedrockChunk.usage || {} } - }; + } } if (bedrockChunk.type === 'message_stop') { @@ -384,39 +398,39 @@ class BedrockRelayService { data: { usage: bedrockChunk.usage || {} } - }; + } } - return null; + return null } // 处理Bedrock错误 _handleBedrockError(error) { - const errorMessage = error.message || 'Unknown Bedrock error'; + const errorMessage = error.message || 'Unknown Bedrock error' if (error.name === 'ValidationException') { - return new Error(`Bedrock参数验证失败: ${errorMessage}`); + return new Error(`Bedrock参数验证失败: ${errorMessage}`) } if (error.name === 'ThrottlingException') { - return new Error('Bedrock请求限流,请稍后重试'); + return new Error('Bedrock请求限流,请稍后重试') } if (error.name === 'AccessDeniedException') { - return new Error('Bedrock访问被拒绝,请检查IAM权限'); + return new Error('Bedrock访问被拒绝,请检查IAM权限') } if (error.name === 'ModelNotReadyException') { - return new Error('Bedrock模型未就绪,请稍后重试'); + return new Error('Bedrock模型未就绪,请稍后重试') } - return new Error(`Bedrock服务错误: ${errorMessage}`); + return new Error(`Bedrock服务错误: ${errorMessage}`) } // 获取可用模型列表 async getAvailableModels(bedrockAccount = null) { try { - const region = bedrockAccount?.region || this.defaultRegion; + const region = bedrockAccount?.region || this.defaultRegion // Bedrock暂不支持列出推理配置文件的API,返回预定义的模型列表 const models = [ @@ -450,16 +464,15 @@ class BedrockRelayService { provider: 'anthropic', type: 'bedrock' } - ]; - - logger.debug(`📋 返回Bedrock可用模型 ${models.length} 个, 区域: ${region}`); - return models; + ] + logger.debug(`📋 返回Bedrock可用模型 ${models.length} 个, 区域: ${region}`) + return models } catch (error) { - logger.error('❌ 获取Bedrock模型列表失败:', error); - return []; + logger.error('❌ 获取Bedrock模型列表失败:', error) + return [] } } } -module.exports = new BedrockRelayService(); \ No newline at end of file +module.exports = new BedrockRelayService() diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index e46a1eb2..9ffa6e42 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -1,29 +1,29 @@ -const { v4: uuidv4 } = require('uuid'); -const crypto = require('crypto'); -const { SocksProxyAgent } = require('socks-proxy-agent'); -const { HttpsProxyAgent } = require('https-proxy-agent'); -const axios = require('axios'); -const redis = require('../models/redis'); -const logger = require('../utils/logger'); -const config = require('../../config/config'); -const { maskToken } = require('../utils/tokenMask'); +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const { SocksProxyAgent } = require('socks-proxy-agent') +const { HttpsProxyAgent } = require('https-proxy-agent') +const axios = require('axios') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const config = require('../../config/config') +const { maskToken } = require('../utils/tokenMask') const { logRefreshStart, logRefreshSuccess, logRefreshError, logTokenUsage, logRefreshSkipped -} = require('../utils/tokenRefreshLogger'); -const tokenRefreshService = require('./tokenRefreshService'); +} = require('../utils/tokenRefreshLogger') +const tokenRefreshService = require('./tokenRefreshService') class ClaudeAccountService { constructor() { - this.claudeApiUrl = 'https://console.anthropic.com/v1/oauth/token'; - this.claudeOauthClientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; - + this.claudeApiUrl = 'https://console.anthropic.com/v1/oauth/token' + this.claudeOauthClientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' + // 加密相关常量 - this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'; - this.ENCRYPTION_SALT = 'salt'; + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'salt' } // 🏢 创建Claude账户 @@ -40,12 +40,12 @@ class ClaudeAccountService { accountType = 'shared', // 'dedicated' or 'shared' priority = 50, // 调度优先级 (1-100,数字越小优先级越高) schedulable = true // 是否可被调度 - } = options; + } = options + + const accountId = uuidv4() + + let accountData - const accountId = uuidv4(); - - let accountData; - if (claudeAiOauth) { // 使用Claude标准格式的OAuth数据 accountData = { @@ -61,15 +61,15 @@ class ClaudeAccountService { scopes: claudeAiOauth.scopes.join(' '), proxy: proxy ? JSON.stringify(proxy) : '', isActive: isActive.toString(), - accountType: accountType, // 账号类型:'dedicated' 或 'shared' + accountType, // 账号类型:'dedicated' 或 'shared' priority: priority.toString(), // 调度优先级 createdAt: new Date().toISOString(), lastUsedAt: '', lastRefreshAt: '', status: 'active', // 有OAuth数据的账户直接设为active errorMessage: '', - schedulable: schedulable.toString(), // 是否可被调度 - }; + schedulable: schedulable.toString() // 是否可被调度 + } } else { // 兼容旧格式 accountData = { @@ -84,21 +84,21 @@ class ClaudeAccountService { scopes: '', proxy: proxy ? JSON.stringify(proxy) : '', isActive: isActive.toString(), - accountType: accountType, // 账号类型:'dedicated' 或 'shared' + accountType, // 账号类型:'dedicated' 或 'shared' priority: priority.toString(), // 调度优先级 createdAt: new Date().toISOString(), lastUsedAt: '', lastRefreshAt: '', status: 'created', // created, active, expired, error errorMessage: '', - schedulable: schedulable.toString(), // 是否可被调度 - }; + schedulable: schedulable.toString() // 是否可被调度 + } } - await redis.setClaudeAccount(accountId, accountData); - - logger.success(`🏢 Created Claude account: ${name} (${accountId})`); - + await redis.setClaudeAccount(accountId, accountData) + + logger.success(`🏢 Created Claude account: ${name} (${accountId})`) + return { id: accountId, name, @@ -112,123 +112,131 @@ class ClaudeAccountService { createdAt: accountData.createdAt, expiresAt: accountData.expiresAt, scopes: claudeAiOauth ? claudeAiOauth.scopes : [] - }; + } } // 🔄 刷新Claude账户token async refreshAccountToken(accountId) { - let lockAcquired = false; - + let lockAcquired = false + try { - const accountData = await redis.getClaudeAccount(accountId); - + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { - throw new Error('Account not found'); + throw new Error('Account not found') } - const refreshToken = this._decryptSensitiveData(accountData.refreshToken); - + const refreshToken = this._decryptSensitiveData(accountData.refreshToken) + if (!refreshToken) { - throw new Error('No refresh token available - manual token update required'); + throw new Error('No refresh token available - manual token update required') } // 尝试获取分布式锁 - lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'claude'); - + lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'claude') + if (!lockAcquired) { // 如果无法获取锁,说明另一个进程正在刷新 - logger.info(`🔒 Token refresh already in progress for account: ${accountData.name} (${accountId})`); - logRefreshSkipped(accountId, accountData.name, 'claude', 'already_locked'); - + logger.info( + `🔒 Token refresh already in progress for account: ${accountData.name} (${accountId})` + ) + logRefreshSkipped(accountId, accountData.name, 'claude', 'already_locked') + // 等待一段时间后返回,期望其他进程已完成刷新 - await new Promise(resolve => setTimeout(resolve, 2000)); - + await new Promise((resolve) => setTimeout(resolve, 2000)) + // 重新获取账户数据(可能已被其他进程刷新) - const updatedData = await redis.getClaudeAccount(accountId); + const updatedData = await redis.getClaudeAccount(accountId) if (updatedData && updatedData.accessToken) { - const accessToken = this._decryptSensitiveData(updatedData.accessToken); + const accessToken = this._decryptSensitiveData(updatedData.accessToken) return { success: true, - accessToken: accessToken, + accessToken, expiresAt: updatedData.expiresAt - }; + } } - - throw new Error('Token refresh in progress by another process'); + + throw new Error('Token refresh in progress by another process') } // 记录开始刷新 - logRefreshStart(accountId, accountData.name, 'claude', 'manual_refresh'); - logger.info(`🔄 Starting token refresh for account: ${accountData.name} (${accountId})`); + logRefreshStart(accountId, accountData.name, 'claude', 'manual_refresh') + logger.info(`🔄 Starting token refresh for account: ${accountData.name} (${accountId})`) // 创建代理agent - const agent = this._createProxyAgent(accountData.proxy); + const agent = this._createProxyAgent(accountData.proxy) - const response = await axios.post(this.claudeApiUrl, { - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: this.claudeOauthClientId - }, { - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json, text/plain, */*', - 'User-Agent': 'claude-cli/1.0.56 (external, cli)', - 'Accept-Language': 'en-US,en;q=0.9', - 'Referer': 'https://claude.ai/', - 'Origin': 'https://claude.ai' + const response = await axios.post( + this.claudeApiUrl, + { + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: this.claudeOauthClientId }, - httpsAgent: agent, - timeout: 30000 - }); + { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/plain, */*', + 'User-Agent': 'claude-cli/1.0.56 (external, cli)', + 'Accept-Language': 'en-US,en;q=0.9', + Referer: 'https://claude.ai/', + Origin: 'https://claude.ai' + }, + httpsAgent: agent, + timeout: 30000 + } + ) if (response.status === 200) { - const { access_token, refresh_token, expires_in } = response.data; - - // 更新账户数据 - accountData.accessToken = this._encryptSensitiveData(access_token); - accountData.refreshToken = this._encryptSensitiveData(refresh_token); - accountData.expiresAt = (Date.now() + (expires_in * 1000)).toString(); - accountData.lastRefreshAt = new Date().toISOString(); - accountData.status = 'active'; - accountData.errorMessage = ''; + const { access_token, refresh_token, expires_in } = response.data + + // 更新账户数据 + accountData.accessToken = this._encryptSensitiveData(access_token) + accountData.refreshToken = this._encryptSensitiveData(refresh_token) + accountData.expiresAt = (Date.now() + expires_in * 1000).toString() + accountData.lastRefreshAt = new Date().toISOString() + accountData.status = 'active' + accountData.errorMessage = '' + + await redis.setClaudeAccount(accountId, accountData) - await redis.setClaudeAccount(accountId, accountData); - // 记录刷新成功 logRefreshSuccess(accountId, accountData.name, 'claude', { accessToken: access_token, refreshToken: refresh_token, expiresAt: accountData.expiresAt, scopes: accountData.scopes - }); - - logger.success(`🔄 Refreshed token for account: ${accountData.name} (${accountId}) - Access Token: ${maskToken(access_token)}`); - + }) + + logger.success( + `🔄 Refreshed token for account: ${accountData.name} (${accountId}) - Access Token: ${maskToken(access_token)}` + ) + return { success: true, accessToken: access_token, expiresAt: accountData.expiresAt - }; + } } else { - throw new Error(`Token refresh failed with status: ${response.status}`); + throw new Error(`Token refresh failed with status: ${response.status}`) } } catch (error) { // 记录刷新失败 - const accountData = await redis.getClaudeAccount(accountId); + const accountData = await redis.getClaudeAccount(accountId) if (accountData) { - logRefreshError(accountId, accountData.name, 'claude', error); - accountData.status = 'error'; - accountData.errorMessage = error.message; - await redis.setClaudeAccount(accountId, accountData); + logRefreshError(accountId, accountData.name, 'claude', error) + accountData.status = 'error' + accountData.errorMessage = error.message + await redis.setClaudeAccount(accountId, accountData) } - - logger.error(`❌ Failed to refresh token for account ${accountId}:`, error); - - throw error; + + logger.error(`❌ Failed to refresh token for account ${accountId}:`, error) + + throw error } finally { // 释放锁 if (lockAcquired) { - await tokenRefreshService.releaseRefreshLock(accountId, 'claude'); + await tokenRefreshService.releaseRefreshLock(accountId, 'claude') } } } @@ -236,252 +244,274 @@ class ClaudeAccountService { // 🔍 获取账户信息 async getAccount(accountId) { try { - const accountData = await redis.getClaudeAccount(accountId); - + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { - return null; + return null } - - - return accountData; + + return accountData } catch (error) { - logger.error('❌ Failed to get Claude account:', error); - return null; + logger.error('❌ Failed to get Claude account:', error) + return null } } // 🎯 获取有效的访问token async getValidAccessToken(accountId) { try { - const accountData = await redis.getClaudeAccount(accountId); - + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { - throw new Error('Account not found'); + throw new Error('Account not found') } if (accountData.isActive !== 'true') { - throw new Error('Account is disabled'); + throw new Error('Account is disabled') } // 检查token是否过期 - const expiresAt = parseInt(accountData.expiresAt); - const now = Date.now(); - const isExpired = !expiresAt || now >= (expiresAt - 60000); // 60秒提前刷新 - + const expiresAt = parseInt(accountData.expiresAt) + const now = Date.now() + const isExpired = !expiresAt || now >= expiresAt - 60000 // 60秒提前刷新 + // 记录token使用情况 - logTokenUsage(accountId, accountData.name, 'claude', accountData.expiresAt, isExpired); - + logTokenUsage(accountId, accountData.name, 'claude', accountData.expiresAt, isExpired) + if (isExpired) { - logger.info(`🔄 Token expired/expiring for account ${accountId}, attempting refresh...`); + logger.info(`🔄 Token expired/expiring for account ${accountId}, attempting refresh...`) try { - const refreshResult = await this.refreshAccountToken(accountId); - return refreshResult.accessToken; + const refreshResult = await this.refreshAccountToken(accountId) + return refreshResult.accessToken } catch (refreshError) { - logger.warn(`⚠️ Token refresh failed for account ${accountId}: ${refreshError.message}`); + logger.warn(`⚠️ Token refresh failed for account ${accountId}: ${refreshError.message}`) // 如果刷新失败,仍然尝试使用当前token(可能是手动添加的长期有效token) - const currentToken = this._decryptSensitiveData(accountData.accessToken); + const currentToken = this._decryptSensitiveData(accountData.accessToken) if (currentToken) { - logger.info(`🔄 Using current token for account ${accountId} (refresh failed)`); - return currentToken; + logger.info(`🔄 Using current token for account ${accountId} (refresh failed)`) + return currentToken } - throw refreshError; + throw refreshError } } - const accessToken = this._decryptSensitiveData(accountData.accessToken); - + const accessToken = this._decryptSensitiveData(accountData.accessToken) + if (!accessToken) { - throw new Error('No access token available'); + throw new Error('No access token available') } // 更新最后使用时间和会话窗口 - accountData.lastUsedAt = new Date().toISOString(); - await this.updateSessionWindow(accountId, accountData); - await redis.setClaudeAccount(accountId, accountData); + accountData.lastUsedAt = new Date().toISOString() + await this.updateSessionWindow(accountId, accountData) + await redis.setClaudeAccount(accountId, accountData) - return accessToken; + return accessToken } catch (error) { - logger.error(`❌ Failed to get valid access token for account ${accountId}:`, error); - throw error; + logger.error(`❌ Failed to get valid access token for account ${accountId}:`, error) + throw error } } // 📋 获取所有Claude账户 async getAllAccounts() { try { - const accounts = await redis.getAllClaudeAccounts(); - + const accounts = await redis.getAllClaudeAccounts() + // 处理返回数据,移除敏感信息并添加限流状态和会话窗口信息 - const processedAccounts = await Promise.all(accounts.map(async account => { - // 获取限流状态信息 - const rateLimitInfo = await this.getAccountRateLimitInfo(account.id); - - // 获取会话窗口信息 - const sessionWindowInfo = await this.getSessionWindowInfo(account.id); - - return { - id: account.id, - name: account.name, - description: account.description, - email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '', - isActive: account.isActive === 'true', - proxy: account.proxy ? JSON.parse(account.proxy) : null, - status: account.status, - errorMessage: account.errorMessage, - accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享 - priority: parseInt(account.priority) || 50, // 兼容旧数据,默认优先级50 - platform: 'claude-oauth', // 添加平台标识,用于前端区分 - createdAt: account.createdAt, - lastUsedAt: account.lastUsedAt, - lastRefreshAt: account.lastRefreshAt, - expiresAt: account.expiresAt, - // 添加限流状态信息 - rateLimitStatus: rateLimitInfo ? { - isRateLimited: rateLimitInfo.isRateLimited, - rateLimitedAt: rateLimitInfo.rateLimitedAt, - minutesRemaining: rateLimitInfo.minutesRemaining - } : null, - // 添加会话窗口信息 - sessionWindow: sessionWindowInfo || { - hasActiveWindow: false, - windowStart: null, - windowEnd: null, - progress: 0, - remainingTime: null, - lastRequestTime: null - }, - // 添加调度状态 - schedulable: account.schedulable !== 'false' // 默认为true,兼容历史数据 - }; - })); - - return processedAccounts; + const processedAccounts = await Promise.all( + accounts.map(async (account) => { + // 获取限流状态信息 + const rateLimitInfo = await this.getAccountRateLimitInfo(account.id) + + // 获取会话窗口信息 + const sessionWindowInfo = await this.getSessionWindowInfo(account.id) + + return { + id: account.id, + name: account.name, + description: account.description, + email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '', + isActive: account.isActive === 'true', + proxy: account.proxy ? JSON.parse(account.proxy) : null, + status: account.status, + errorMessage: account.errorMessage, + accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享 + priority: parseInt(account.priority) || 50, // 兼容旧数据,默认优先级50 + platform: 'claude-oauth', // 添加平台标识,用于前端区分 + createdAt: account.createdAt, + lastUsedAt: account.lastUsedAt, + lastRefreshAt: account.lastRefreshAt, + expiresAt: account.expiresAt, + // 添加限流状态信息 + rateLimitStatus: rateLimitInfo + ? { + isRateLimited: rateLimitInfo.isRateLimited, + rateLimitedAt: rateLimitInfo.rateLimitedAt, + minutesRemaining: rateLimitInfo.minutesRemaining + } + : null, + // 添加会话窗口信息 + sessionWindow: sessionWindowInfo || { + hasActiveWindow: false, + windowStart: null, + windowEnd: null, + progress: 0, + remainingTime: null, + lastRequestTime: null + }, + // 添加调度状态 + schedulable: account.schedulable !== 'false' // 默认为true,兼容历史数据 + } + }) + ) + + return processedAccounts } catch (error) { - logger.error('❌ Failed to get Claude accounts:', error); - throw error; + logger.error('❌ Failed to get Claude accounts:', error) + throw error } } // 📝 更新Claude账户 async updateAccount(accountId, updates) { try { - const accountData = await redis.getClaudeAccount(accountId); - + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { - throw new Error('Account not found'); + throw new Error('Account not found') } - const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive', 'claudeAiOauth', 'accountType', 'priority', 'schedulable']; - const updatedData = { ...accountData }; + const allowedUpdates = [ + 'name', + 'description', + 'email', + 'password', + 'refreshToken', + 'proxy', + 'isActive', + 'claudeAiOauth', + 'accountType', + 'priority', + 'schedulable' + ] + const updatedData = { ...accountData } // 检查是否新增了 refresh token - const oldRefreshToken = this._decryptSensitiveData(accountData.refreshToken); - + const oldRefreshToken = this._decryptSensitiveData(accountData.refreshToken) + for (const [field, value] of Object.entries(updates)) { if (allowedUpdates.includes(field)) { if (['email', 'password', 'refreshToken'].includes(field)) { - updatedData[field] = this._encryptSensitiveData(value); + updatedData[field] = this._encryptSensitiveData(value) } else if (field === 'proxy') { - updatedData[field] = value ? JSON.stringify(value) : ''; + updatedData[field] = value ? JSON.stringify(value) : '' } else if (field === 'priority') { - updatedData[field] = value.toString(); + updatedData[field] = value.toString() } else if (field === 'claudeAiOauth') { // 更新 Claude AI OAuth 数据 if (value) { - updatedData.claudeAiOauth = this._encryptSensitiveData(JSON.stringify(value)); - updatedData.accessToken = this._encryptSensitiveData(value.accessToken); - updatedData.refreshToken = this._encryptSensitiveData(value.refreshToken); - updatedData.expiresAt = value.expiresAt.toString(); - updatedData.scopes = value.scopes.join(' '); - updatedData.status = 'active'; - updatedData.errorMessage = ''; - updatedData.lastRefreshAt = new Date().toISOString(); + updatedData.claudeAiOauth = this._encryptSensitiveData(JSON.stringify(value)) + updatedData.accessToken = this._encryptSensitiveData(value.accessToken) + updatedData.refreshToken = this._encryptSensitiveData(value.refreshToken) + updatedData.expiresAt = value.expiresAt.toString() + updatedData.scopes = value.scopes.join(' ') + updatedData.status = 'active' + updatedData.errorMessage = '' + updatedData.lastRefreshAt = new Date().toISOString() } } else { - updatedData[field] = value.toString(); + updatedData[field] = value.toString() } } } - + // 如果新增了 refresh token(之前没有,现在有了),更新过期时间为10分钟 if (updates.refreshToken && !oldRefreshToken && updates.refreshToken.trim()) { - const newExpiresAt = Date.now() + (10 * 60 * 1000); // 10分钟 - updatedData.expiresAt = newExpiresAt.toString(); - logger.info(`🔄 New refresh token added for account ${accountId}, setting expiry to 10 minutes`); + const newExpiresAt = Date.now() + 10 * 60 * 1000 // 10分钟 + updatedData.expiresAt = newExpiresAt.toString() + logger.info( + `🔄 New refresh token added for account ${accountId}, setting expiry to 10 minutes` + ) } - + // 如果通过 claudeAiOauth 更新,也要检查是否新增了 refresh token if (updates.claudeAiOauth && updates.claudeAiOauth.refreshToken && !oldRefreshToken) { // 如果 expiresAt 设置的时间过长(超过1小时),调整为10分钟 - const providedExpiry = parseInt(updates.claudeAiOauth.expiresAt); - const now = Date.now(); - const oneHour = 60 * 60 * 1000; - + const providedExpiry = parseInt(updates.claudeAiOauth.expiresAt) + const now = Date.now() + const oneHour = 60 * 60 * 1000 + if (providedExpiry - now > oneHour) { - const newExpiresAt = now + (10 * 60 * 1000); // 10分钟 - updatedData.expiresAt = newExpiresAt.toString(); - logger.info(`🔄 Adjusted expiry time to 10 minutes for account ${accountId} with refresh token`); + const newExpiresAt = now + 10 * 60 * 1000 // 10分钟 + updatedData.expiresAt = newExpiresAt.toString() + logger.info( + `🔄 Adjusted expiry time to 10 minutes for account ${accountId} with refresh token` + ) } } - updatedData.updatedAt = new Date().toISOString(); - - await redis.setClaudeAccount(accountId, updatedData); - - logger.success(`📝 Updated Claude account: ${accountId}`); - - return { success: true }; + updatedData.updatedAt = new Date().toISOString() + + await redis.setClaudeAccount(accountId, updatedData) + + logger.success(`📝 Updated Claude account: ${accountId}`) + + return { success: true } } catch (error) { - logger.error('❌ Failed to update Claude account:', error); - throw error; + logger.error('❌ Failed to update Claude account:', error) + throw error } } // 🗑️ 删除Claude账户 async deleteAccount(accountId) { try { - const result = await redis.deleteClaudeAccount(accountId); - + const result = await redis.deleteClaudeAccount(accountId) + if (result === 0) { - throw new Error('Account not found'); + throw new Error('Account not found') } - - logger.success(`🗑️ Deleted Claude account: ${accountId}`); - - return { success: true }; + + logger.success(`🗑️ Deleted Claude account: ${accountId}`) + + return { success: true } } catch (error) { - logger.error('❌ Failed to delete Claude account:', error); - throw error; + logger.error('❌ Failed to delete Claude account:', error) + throw error } } // 🎯 智能选择可用账户(支持sticky会话) async selectAvailableAccount(sessionHash = null) { try { - const accounts = await redis.getAllClaudeAccounts(); - - const activeAccounts = accounts.filter(account => - account.isActive === 'true' && - account.status !== 'error' - ); + const accounts = await redis.getAllClaudeAccounts() + + const activeAccounts = accounts.filter( + (account) => account.isActive === 'true' && account.status !== 'error' + ) if (activeAccounts.length === 0) { - throw new Error('No active Claude accounts available'); + throw new Error('No active Claude accounts available') } // 如果有会话哈希,检查是否有已映射的账户 if (sessionHash) { - const mappedAccountId = await redis.getSessionAccountMapping(sessionHash); + const mappedAccountId = await redis.getSessionAccountMapping(sessionHash) if (mappedAccountId) { // 验证映射的账户是否仍然可用 - const mappedAccount = activeAccounts.find(acc => acc.id === mappedAccountId); + const mappedAccount = activeAccounts.find((acc) => acc.id === mappedAccountId) if (mappedAccount) { - logger.info(`🎯 Using sticky session account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`); - return mappedAccountId; + logger.info( + `🎯 Using sticky session account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}` + ) + return mappedAccountId } else { - logger.warn(`⚠️ Mapped account ${mappedAccountId} is no longer available, selecting new account`); + logger.warn( + `⚠️ Mapped account ${mappedAccountId} is no longer available, selecting new account` + ) // 清理无效的映射 - await redis.deleteSessionAccountMapping(sessionHash); + await redis.deleteSessionAccountMapping(sessionHash) } } } @@ -489,23 +519,25 @@ class ClaudeAccountService { // 如果没有映射或映射无效,选择新账户 // 优先选择最久未使用的账户(负载均衡) const sortedAccounts = activeAccounts.sort((a, b) => { - const aLastUsed = new Date(a.lastUsedAt || 0).getTime(); - const bLastUsed = new Date(b.lastUsedAt || 0).getTime(); - return aLastUsed - bLastUsed; // 最久未使用的优先 - }); + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed // 最久未使用的优先 + }) + + const selectedAccountId = sortedAccounts[0].id - const selectedAccountId = sortedAccounts[0].id; - // 如果有会话哈希,建立新的映射 if (sessionHash) { - await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600); // 1小时过期 - logger.info(`🎯 Created new sticky session mapping: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`); + await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期 + logger.info( + `🎯 Created new sticky session mapping: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}` + ) } - return selectedAccountId; + return selectedAccountId } catch (error) { - logger.error('❌ Failed to select available account:', error); - throw error; + logger.error('❌ Failed to select available account:', error) + throw error } } @@ -514,397 +546,425 @@ class ClaudeAccountService { try { // 如果API Key绑定了专属账户,优先使用 if (apiKeyData.claudeAccountId) { - const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId); + const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { - logger.info(`🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`); - return apiKeyData.claudeAccountId; + logger.info( + `🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` + ) + return apiKeyData.claudeAccountId } else { - logger.warn(`⚠️ Bound account ${apiKeyData.claudeAccountId} is not available, falling back to shared pool`); + logger.warn( + `⚠️ Bound account ${apiKeyData.claudeAccountId} is not available, falling back to shared pool` + ) } } // 如果没有绑定账户或绑定账户不可用,从共享池选择 - const accounts = await redis.getAllClaudeAccounts(); - - const sharedAccounts = accounts.filter(account => - account.isActive === 'true' && - account.status !== 'error' && - (account.accountType === 'shared' || !account.accountType) // 兼容旧数据 - ); + const accounts = await redis.getAllClaudeAccounts() + + const sharedAccounts = accounts.filter( + (account) => + account.isActive === 'true' && + account.status !== 'error' && + (account.accountType === 'shared' || !account.accountType) // 兼容旧数据 + ) if (sharedAccounts.length === 0) { - throw new Error('No active shared Claude accounts available'); + throw new Error('No active shared Claude accounts available') } // 如果有会话哈希,检查是否有已映射的账户 if (sessionHash) { - const mappedAccountId = await redis.getSessionAccountMapping(sessionHash); + const mappedAccountId = await redis.getSessionAccountMapping(sessionHash) if (mappedAccountId) { // 验证映射的账户是否仍然在共享池中且可用 - const mappedAccount = sharedAccounts.find(acc => acc.id === mappedAccountId); + const mappedAccount = sharedAccounts.find((acc) => acc.id === mappedAccountId) if (mappedAccount) { // 如果映射的账户被限流了,删除映射并重新选择 - const isRateLimited = await this.isAccountRateLimited(mappedAccountId); + const isRateLimited = await this.isAccountRateLimited(mappedAccountId) if (isRateLimited) { - logger.warn(`⚠️ Mapped account ${mappedAccountId} is rate limited, selecting new account`); - await redis.deleteSessionAccountMapping(sessionHash); + logger.warn( + `⚠️ Mapped account ${mappedAccountId} is rate limited, selecting new account` + ) + await redis.deleteSessionAccountMapping(sessionHash) } else { - logger.info(`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`); - return mappedAccountId; + logger.info( + `🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}` + ) + return mappedAccountId } } else { - logger.warn(`⚠️ Mapped shared account ${mappedAccountId} is no longer available, selecting new account`); + logger.warn( + `⚠️ Mapped shared account ${mappedAccountId} is no longer available, selecting new account` + ) // 清理无效的映射 - await redis.deleteSessionAccountMapping(sessionHash); + await redis.deleteSessionAccountMapping(sessionHash) } } } // 将账户分为限流和非限流两组 - const nonRateLimitedAccounts = []; - const rateLimitedAccounts = []; - + const nonRateLimitedAccounts = [] + const rateLimitedAccounts = [] + for (const account of sharedAccounts) { - const isRateLimited = await this.isAccountRateLimited(account.id); + const isRateLimited = await this.isAccountRateLimited(account.id) if (isRateLimited) { - const rateLimitInfo = await this.getAccountRateLimitInfo(account.id); - account._rateLimitInfo = rateLimitInfo; // 临时存储限流信息 - rateLimitedAccounts.push(account); + const rateLimitInfo = await this.getAccountRateLimitInfo(account.id) + account._rateLimitInfo = rateLimitInfo // 临时存储限流信息 + rateLimitedAccounts.push(account) } else { - nonRateLimitedAccounts.push(account); + nonRateLimitedAccounts.push(account) } } // 优先从非限流账户中选择 - let candidateAccounts = nonRateLimitedAccounts; - + let candidateAccounts = nonRateLimitedAccounts + // 如果没有非限流账户,则从限流账户中选择(按限流时间排序,最早限流的优先) if (candidateAccounts.length === 0) { - logger.warn('⚠️ All shared accounts are rate limited, selecting from rate limited pool'); + logger.warn('⚠️ All shared accounts are rate limited, selecting from rate limited pool') candidateAccounts = rateLimitedAccounts.sort((a, b) => { - const aRateLimitedAt = new Date(a._rateLimitInfo.rateLimitedAt).getTime(); - const bRateLimitedAt = new Date(b._rateLimitInfo.rateLimitedAt).getTime(); - return aRateLimitedAt - bRateLimitedAt; // 最早限流的优先 - }); + const aRateLimitedAt = new Date(a._rateLimitInfo.rateLimitedAt).getTime() + const bRateLimitedAt = new Date(b._rateLimitInfo.rateLimitedAt).getTime() + return aRateLimitedAt - bRateLimitedAt // 最早限流的优先 + }) } else { // 非限流账户按最后使用时间排序(最久未使用的优先) candidateAccounts = candidateAccounts.sort((a, b) => { - const aLastUsed = new Date(a.lastUsedAt || 0).getTime(); - const bLastUsed = new Date(b.lastUsedAt || 0).getTime(); - return aLastUsed - bLastUsed; // 最久未使用的优先 - }); + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed // 最久未使用的优先 + }) } if (candidateAccounts.length === 0) { - throw new Error('No available shared Claude accounts'); + throw new Error('No available shared Claude accounts') } - const selectedAccountId = candidateAccounts[0].id; - + const selectedAccountId = candidateAccounts[0].id + // 如果有会话哈希,建立新的映射 if (sessionHash) { - await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600); // 1小时过期 - logger.info(`🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`); + await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期 + logger.info( + `🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}` + ) } - logger.info(`🎯 Selected shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}`); - return selectedAccountId; + logger.info( + `🎯 Selected shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}` + ) + return selectedAccountId } catch (error) { - logger.error('❌ Failed to select account for API key:', error); - throw error; + logger.error('❌ Failed to select account for API key:', error) + throw error } } // 🌐 创建代理agent _createProxyAgent(proxyConfig) { if (!proxyConfig) { - return null; + return null } try { - const proxy = JSON.parse(proxyConfig); - + const proxy = JSON.parse(proxyConfig) + if (proxy.type === 'socks5') { - const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''; - const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`; - return new SocksProxyAgent(socksUrl); + const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' + const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}` + return new SocksProxyAgent(socksUrl) } else if (proxy.type === 'http' || proxy.type === 'https') { - const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''; - const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`; - return new HttpsProxyAgent(httpUrl); + const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' + const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}` + return new HttpsProxyAgent(httpUrl) } } catch (error) { - logger.warn('⚠️ Invalid proxy configuration:', error); + logger.warn('⚠️ Invalid proxy configuration:', error) } - return null; + return null } // 🔐 加密敏感数据 _encryptSensitiveData(data) { - if (!data) return ''; - + if (!data) { + return '' + } + try { - const key = this._generateEncryptionKey(); - const iv = crypto.randomBytes(16); - - const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv); - let encrypted = cipher.update(data, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - + const key = this._generateEncryptionKey() + const iv = crypto.randomBytes(16) + + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let encrypted = cipher.update(data, 'utf8', 'hex') + encrypted += cipher.final('hex') + // 将IV和加密数据一起返回,用:分隔 - return iv.toString('hex') + ':' + encrypted; + return `${iv.toString('hex')}:${encrypted}` } catch (error) { - logger.error('❌ Encryption error:', error); - return data; + logger.error('❌ Encryption error:', error) + return data } } // 🔓 解密敏感数据 _decryptSensitiveData(encryptedData) { - if (!encryptedData) return ''; - + if (!encryptedData) { + return '' + } + try { // 检查是否是新格式(包含IV) if (encryptedData.includes(':')) { // 新格式:iv:encryptedData - const parts = encryptedData.split(':'); + const parts = encryptedData.split(':') if (parts.length === 2) { - const key = this._generateEncryptionKey(); - const iv = Buffer.from(parts[0], 'hex'); - const encrypted = parts[1]; - - const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv); - let decrypted = decipher.update(encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; + const key = this._generateEncryptionKey() + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted } } - + // 旧格式或格式错误,尝试旧方式解密(向后兼容) // 注意:在新版本Node.js中这将失败,但我们会捕获错误 try { - const decipher = crypto.createDecipher('aes-256-cbc', config.security.encryptionKey); - let decrypted = decipher.update(encryptedData, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; + const decipher = crypto.createDecipher('aes-256-cbc', config.security.encryptionKey) + let decrypted = decipher.update(encryptedData, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted } catch (oldError) { // 如果旧方式也失败,返回原数据 - logger.warn('⚠️ Could not decrypt data, returning as-is:', oldError.message); - return encryptedData; + logger.warn('⚠️ Could not decrypt data, returning as-is:', oldError.message) + return encryptedData } } catch (error) { - logger.error('❌ Decryption error:', error); - return encryptedData; + logger.error('❌ Decryption error:', error) + return encryptedData } } // 🔑 生成加密密钥(辅助方法) _generateEncryptionKey() { - return crypto.scryptSync(config.security.encryptionKey, this.ENCRYPTION_SALT, 32); + return crypto.scryptSync(config.security.encryptionKey, this.ENCRYPTION_SALT, 32) } // 🎭 掩码邮箱地址 _maskEmail(email) { - if (!email || !email.includes('@')) return email; - - const [username, domain] = email.split('@'); - const maskedUsername = username.length > 2 - ? `${username.slice(0, 2)}***${username.slice(-1)}` - : `${username.slice(0, 1)}***`; - - return `${maskedUsername}@${domain}`; + if (!email || !email.includes('@')) { + return email + } + + const [username, domain] = email.split('@') + const maskedUsername = + username.length > 2 + ? `${username.slice(0, 2)}***${username.slice(-1)}` + : `${username.slice(0, 1)}***` + + return `${maskedUsername}@${domain}` } // 🧹 清理错误账户 async cleanupErrorAccounts() { try { - const accounts = await redis.getAllClaudeAccounts(); - let cleanedCount = 0; + const accounts = await redis.getAllClaudeAccounts() + let cleanedCount = 0 for (const account of accounts) { if (account.status === 'error' && account.lastRefreshAt) { - const lastRefresh = new Date(account.lastRefreshAt); - const now = new Date(); - const hoursSinceLastRefresh = (now - lastRefresh) / (1000 * 60 * 60); + const lastRefresh = new Date(account.lastRefreshAt) + const now = new Date() + const hoursSinceLastRefresh = (now - lastRefresh) / (1000 * 60 * 60) // 如果错误状态超过24小时,尝试重新激活 if (hoursSinceLastRefresh > 24) { - account.status = 'created'; - account.errorMessage = ''; - await redis.setClaudeAccount(account.id, account); - cleanedCount++; + account.status = 'created' + account.errorMessage = '' + await redis.setClaudeAccount(account.id, account) + cleanedCount++ } } } if (cleanedCount > 0) { - logger.success(`🧹 Reset ${cleanedCount} error accounts`); + logger.success(`🧹 Reset ${cleanedCount} error accounts`) } - return cleanedCount; + return cleanedCount } catch (error) { - logger.error('❌ Failed to cleanup error accounts:', error); - return 0; + logger.error('❌ Failed to cleanup error accounts:', error) + return 0 } } // 🚫 标记账号为限流状态 async markAccountRateLimited(accountId, sessionHash = null, rateLimitResetTimestamp = null) { try { - const accountData = await redis.getClaudeAccount(accountId); + const accountData = await redis.getClaudeAccount(accountId) if (!accountData || Object.keys(accountData).length === 0) { - throw new Error('Account not found'); + throw new Error('Account not found') } // 设置限流状态和时间 - const updatedAccountData = { ...accountData }; - updatedAccountData.rateLimitedAt = new Date().toISOString(); - updatedAccountData.rateLimitStatus = 'limited'; - + const updatedAccountData = { ...accountData } + updatedAccountData.rateLimitedAt = new Date().toISOString() + updatedAccountData.rateLimitStatus = 'limited' + // 如果提供了准确的限流重置时间戳(来自API响应头) if (rateLimitResetTimestamp) { // 将Unix时间戳(秒)转换为毫秒并创建Date对象 - const resetTime = new Date(rateLimitResetTimestamp * 1000); - updatedAccountData.rateLimitEndAt = resetTime.toISOString(); - + const resetTime = new Date(rateLimitResetTimestamp * 1000) + updatedAccountData.rateLimitEndAt = resetTime.toISOString() + // 计算当前会话窗口的开始时间(重置时间减去5小时) - const windowStartTime = new Date(resetTime.getTime() - (5 * 60 * 60 * 1000)); - updatedAccountData.sessionWindowStart = windowStartTime.toISOString(); - updatedAccountData.sessionWindowEnd = resetTime.toISOString(); - - const now = new Date(); - const minutesUntilEnd = Math.ceil((resetTime - now) / (1000 * 60)); - logger.warn(`🚫 Account marked as rate limited with accurate reset time: ${accountData.name} (${accountId}) - ${minutesUntilEnd} minutes remaining until ${resetTime.toISOString()}`); + const windowStartTime = new Date(resetTime.getTime() - 5 * 60 * 60 * 1000) + updatedAccountData.sessionWindowStart = windowStartTime.toISOString() + updatedAccountData.sessionWindowEnd = resetTime.toISOString() + + const now = new Date() + const minutesUntilEnd = Math.ceil((resetTime - now) / (1000 * 60)) + logger.warn( + `🚫 Account marked as rate limited with accurate reset time: ${accountData.name} (${accountId}) - ${minutesUntilEnd} minutes remaining until ${resetTime.toISOString()}` + ) } else { // 获取或创建会话窗口(预估方式) - const windowData = await this.updateSessionWindow(accountId, updatedAccountData); - Object.assign(updatedAccountData, windowData); - + const windowData = await this.updateSessionWindow(accountId, updatedAccountData) + Object.assign(updatedAccountData, windowData) + // 限流结束时间 = 会话窗口结束时间 if (updatedAccountData.sessionWindowEnd) { - updatedAccountData.rateLimitEndAt = updatedAccountData.sessionWindowEnd; - const windowEnd = new Date(updatedAccountData.sessionWindowEnd); - const now = new Date(); - const minutesUntilEnd = Math.ceil((windowEnd - now) / (1000 * 60)); - logger.warn(`🚫 Account marked as rate limited until estimated session window ends: ${accountData.name} (${accountId}) - ${minutesUntilEnd} minutes remaining`); + updatedAccountData.rateLimitEndAt = updatedAccountData.sessionWindowEnd + const windowEnd = new Date(updatedAccountData.sessionWindowEnd) + const now = new Date() + const minutesUntilEnd = Math.ceil((windowEnd - now) / (1000 * 60)) + logger.warn( + `🚫 Account marked as rate limited until estimated session window ends: ${accountData.name} (${accountId}) - ${minutesUntilEnd} minutes remaining` + ) } else { // 如果没有会话窗口,使用默认1小时(兼容旧逻辑) - const oneHourLater = new Date(Date.now() + 60 * 60 * 1000); - updatedAccountData.rateLimitEndAt = oneHourLater.toISOString(); - logger.warn(`🚫 Account marked as rate limited (1 hour default): ${accountData.name} (${accountId})`); + const oneHourLater = new Date(Date.now() + 60 * 60 * 1000) + updatedAccountData.rateLimitEndAt = oneHourLater.toISOString() + logger.warn( + `🚫 Account marked as rate limited (1 hour default): ${accountData.name} (${accountId})` + ) } } - - await redis.setClaudeAccount(accountId, updatedAccountData); + + await redis.setClaudeAccount(accountId, updatedAccountData) // 如果有会话哈希,删除粘性会话映射 if (sessionHash) { - await redis.deleteSessionAccountMapping(sessionHash); - logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`); + await redis.deleteSessionAccountMapping(sessionHash) + logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`) } - return { success: true }; + return { success: true } } catch (error) { - logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error); - throw error; + logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error) + throw error } } // ✅ 移除账号的限流状态 async removeAccountRateLimit(accountId) { try { - const accountData = await redis.getClaudeAccount(accountId); + const accountData = await redis.getClaudeAccount(accountId) if (!accountData || Object.keys(accountData).length === 0) { - throw new Error('Account not found'); + throw new Error('Account not found') } // 清除限流状态 - delete accountData.rateLimitedAt; - delete accountData.rateLimitStatus; - delete accountData.rateLimitEndAt; // 清除限流结束时间 - await redis.setClaudeAccount(accountId, accountData); + delete accountData.rateLimitedAt + delete accountData.rateLimitStatus + delete accountData.rateLimitEndAt // 清除限流结束时间 + await redis.setClaudeAccount(accountId, accountData) - logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`); - return { success: true }; + logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`) + return { success: true } } catch (error) { - logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error); - throw error; + logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error) + throw error } } // 🔍 检查账号是否处于限流状态 async isAccountRateLimited(accountId) { try { - const accountData = await redis.getClaudeAccount(accountId); + const accountData = await redis.getClaudeAccount(accountId) if (!accountData || Object.keys(accountData).length === 0) { - return false; + return false } // 检查是否有限流状态 if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) { - const now = new Date(); - + const now = new Date() + // 优先使用 rateLimitEndAt(基于会话窗口) if (accountData.rateLimitEndAt) { - const rateLimitEndAt = new Date(accountData.rateLimitEndAt); - + const rateLimitEndAt = new Date(accountData.rateLimitEndAt) + // 如果当前时间超过限流结束时间,自动解除 if (now >= rateLimitEndAt) { - await this.removeAccountRateLimit(accountId); - return false; + await this.removeAccountRateLimit(accountId) + return false } - - return true; + + return true } else { // 兼容旧数据:使用1小时限流 - const rateLimitedAt = new Date(accountData.rateLimitedAt); - const hoursSinceRateLimit = (now - rateLimitedAt) / (1000 * 60 * 60); + const rateLimitedAt = new Date(accountData.rateLimitedAt) + const hoursSinceRateLimit = (now - rateLimitedAt) / (1000 * 60 * 60) // 如果限流超过1小时,自动解除 if (hoursSinceRateLimit >= 1) { - await this.removeAccountRateLimit(accountId); - return false; + await this.removeAccountRateLimit(accountId) + return false } - return true; + return true } } - return false; + return false } catch (error) { - logger.error(`❌ Failed to check rate limit status for account: ${accountId}`, error); - return false; + logger.error(`❌ Failed to check rate limit status for account: ${accountId}`, error) + return false } } // 📊 获取账号的限流信息 async getAccountRateLimitInfo(accountId) { try { - const accountData = await redis.getClaudeAccount(accountId); + const accountData = await redis.getClaudeAccount(accountId) if (!accountData || Object.keys(accountData).length === 0) { - return null; + return null } if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) { - const rateLimitedAt = new Date(accountData.rateLimitedAt); - const now = new Date(); - const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60)); - - let minutesRemaining; - let rateLimitEndAt; - + const rateLimitedAt = new Date(accountData.rateLimitedAt) + const now = new Date() + const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60)) + + let minutesRemaining + let rateLimitEndAt + // 优先使用 rateLimitEndAt(基于会话窗口) if (accountData.rateLimitEndAt) { - rateLimitEndAt = accountData.rateLimitEndAt; - const endTime = new Date(accountData.rateLimitEndAt); - minutesRemaining = Math.max(0, Math.ceil((endTime - now) / (1000 * 60))); + ;({ rateLimitEndAt } = accountData) + const endTime = new Date(accountData.rateLimitEndAt) + minutesRemaining = Math.max(0, Math.ceil((endTime - now) / (1000 * 60))) } else { // 兼容旧数据:使用1小时限流 - minutesRemaining = Math.max(0, 60 - minutesSinceRateLimit); + minutesRemaining = Math.max(0, 60 - minutesSinceRateLimit) // 计算预期的结束时间 - const endTime = new Date(rateLimitedAt.getTime() + 60 * 60 * 1000); - rateLimitEndAt = endTime.toISOString(); + const endTime = new Date(rateLimitedAt.getTime() + 60 * 60 * 1000) + rateLimitEndAt = endTime.toISOString() } return { @@ -912,8 +972,8 @@ class ClaudeAccountService { rateLimitedAt: accountData.rateLimitedAt, minutesSinceRateLimit, minutesRemaining, - rateLimitEndAt // 新增:限流结束时间 - }; + rateLimitEndAt // 新增:限流结束时间 + } } return { @@ -922,10 +982,10 @@ class ClaudeAccountService { minutesSinceRateLimit: 0, minutesRemaining: 0, rateLimitEndAt: null - }; + } } catch (error) { - logger.error(`❌ Failed to get rate limit info for account: ${accountId}`, error); - return null; + logger.error(`❌ Failed to get rate limit info for account: ${accountId}`, error) + return null } } @@ -934,72 +994,76 @@ class ClaudeAccountService { try { // 如果没有传入accountData,从Redis获取 if (!accountData) { - accountData = await redis.getClaudeAccount(accountId); + accountData = await redis.getClaudeAccount(accountId) if (!accountData || Object.keys(accountData).length === 0) { - throw new Error('Account not found'); + throw new Error('Account not found') } } - const now = new Date(); - const currentTime = now.getTime(); - + const now = new Date() + const currentTime = now.getTime() + // 检查当前是否有活跃的会话窗口 if (accountData.sessionWindowStart && accountData.sessionWindowEnd) { - const windowEnd = new Date(accountData.sessionWindowEnd).getTime(); - + const windowEnd = new Date(accountData.sessionWindowEnd).getTime() + // 如果当前时间在窗口内,只更新最后请求时间 if (currentTime < windowEnd) { - accountData.lastRequestTime = now.toISOString(); - return accountData; + accountData.lastRequestTime = now.toISOString() + return accountData } - + // 窗口已过期,记录日志 - const windowStart = new Date(accountData.sessionWindowStart); - logger.info(`⏰ Session window expired for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${new Date(windowEnd).toISOString()}`); + const windowStart = new Date(accountData.sessionWindowStart) + logger.info( + `⏰ Session window expired for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${new Date(windowEnd).toISOString()}` + ) } // 基于当前时间计算新的会话窗口 - const windowStart = this._calculateSessionWindowStart(now); - const windowEnd = this._calculateSessionWindowEnd(windowStart); + const windowStart = this._calculateSessionWindowStart(now) + const windowEnd = this._calculateSessionWindowEnd(windowStart) // 更新会话窗口信息 - accountData.sessionWindowStart = windowStart.toISOString(); - accountData.sessionWindowEnd = windowEnd.toISOString(); - accountData.lastRequestTime = now.toISOString(); + accountData.sessionWindowStart = windowStart.toISOString() + accountData.sessionWindowEnd = windowEnd.toISOString() + accountData.lastRequestTime = now.toISOString() - logger.info(`🕐 Created new session window for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${windowEnd.toISOString()} (from current time)`); + logger.info( + `🕐 Created new session window for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${windowEnd.toISOString()} (from current time)` + ) - return accountData; + return accountData } catch (error) { - logger.error(`❌ Failed to update session window for account ${accountId}:`, error); - throw error; + logger.error(`❌ Failed to update session window for account ${accountId}:`, error) + throw error } } // 🕐 计算会话窗口开始时间 _calculateSessionWindowStart(requestTime) { // 从当前时间开始创建窗口,只将分钟取整到整点 - const windowStart = new Date(requestTime); - windowStart.setMinutes(0); - windowStart.setSeconds(0); - windowStart.setMilliseconds(0); - - return windowStart; + const windowStart = new Date(requestTime) + windowStart.setMinutes(0) + windowStart.setSeconds(0) + windowStart.setMilliseconds(0) + + return windowStart } // 🕐 计算会话窗口结束时间 _calculateSessionWindowEnd(startTime) { - const endTime = new Date(startTime); - endTime.setHours(endTime.getHours() + 5); // 加5小时 - return endTime; + const endTime = new Date(startTime) + endTime.setHours(endTime.getHours() + 5) // 加5小时 + return endTime } // 📊 获取会话窗口信息 async getSessionWindowInfo(accountId) { try { - const accountData = await redis.getClaudeAccount(accountId); + const accountData = await redis.getClaudeAccount(accountId) if (!accountData || Object.keys(accountData).length === 0) { - return null; + return null } // 如果没有会话窗口信息,返回null @@ -1011,13 +1075,13 @@ class ClaudeAccountService { progress: 0, remainingTime: null, lastRequestTime: accountData.lastRequestTime || null - }; + } } - const now = new Date(); - const windowStart = new Date(accountData.sessionWindowStart); - const windowEnd = new Date(accountData.sessionWindowEnd); - const currentTime = now.getTime(); + const now = new Date() + const windowStart = new Date(accountData.sessionWindowStart) + const windowEnd = new Date(accountData.sessionWindowEnd) + const currentTime = now.getTime() // 检查窗口是否已过期 if (currentTime >= windowEnd.getTime()) { @@ -1028,16 +1092,16 @@ class ClaudeAccountService { progress: 100, remainingTime: 0, lastRequestTime: accountData.lastRequestTime || null - }; + } } // 计算进度百分比 - const totalDuration = windowEnd.getTime() - windowStart.getTime(); - const elapsedTime = currentTime - windowStart.getTime(); - const progress = Math.round((elapsedTime / totalDuration) * 100); + const totalDuration = windowEnd.getTime() - windowStart.getTime() + const elapsedTime = currentTime - windowStart.getTime() + const progress = Math.round((elapsedTime / totalDuration) * 100) // 计算剩余时间(分钟) - const remainingTime = Math.round((windowEnd.getTime() - currentTime) / (1000 * 60)); + const remainingTime = Math.round((windowEnd.getTime() - currentTime) / (1000 * 60)) return { hasActiveWindow: true, @@ -1046,84 +1110,90 @@ class ClaudeAccountService { progress, remainingTime, lastRequestTime: accountData.lastRequestTime || null - }; + } } catch (error) { - logger.error(`❌ Failed to get session window info for account ${accountId}:`, error); - return null; + logger.error(`❌ Failed to get session window info for account ${accountId}:`, error) + return null } } // 🔄 初始化所有账户的会话窗口(从历史数据恢复) async initializeSessionWindows(forceRecalculate = false) { try { - logger.info('🔄 Initializing session windows for all Claude accounts...'); - - const accounts = await redis.getAllClaudeAccounts(); - let validWindowCount = 0; - let expiredWindowCount = 0; - let noWindowCount = 0; - const now = new Date(); - + logger.info('🔄 Initializing session windows for all Claude accounts...') + + const accounts = await redis.getAllClaudeAccounts() + let validWindowCount = 0 + let expiredWindowCount = 0 + let noWindowCount = 0 + const now = new Date() + for (const account of accounts) { // 如果强制重算,清除现有窗口信息 if (forceRecalculate && (account.sessionWindowStart || account.sessionWindowEnd)) { - logger.info(`🔄 Force recalculating window for account ${account.name} (${account.id})`); - delete account.sessionWindowStart; - delete account.sessionWindowEnd; - delete account.lastRequestTime; - await redis.setClaudeAccount(account.id, account); + logger.info(`🔄 Force recalculating window for account ${account.name} (${account.id})`) + delete account.sessionWindowStart + delete account.sessionWindowEnd + delete account.lastRequestTime + await redis.setClaudeAccount(account.id, account) } - + // 检查现有会话窗口 if (account.sessionWindowStart && account.sessionWindowEnd) { - const windowEnd = new Date(account.sessionWindowEnd); - const windowStart = new Date(account.sessionWindowStart); - const timeUntilExpires = Math.round((windowEnd.getTime() - now.getTime()) / (1000 * 60)); - + const windowEnd = new Date(account.sessionWindowEnd) + const windowStart = new Date(account.sessionWindowStart) + const timeUntilExpires = Math.round((windowEnd.getTime() - now.getTime()) / (1000 * 60)) + if (now.getTime() < windowEnd.getTime()) { // 窗口仍然有效,保留它 - validWindowCount++; - logger.info(`✅ Account ${account.name} (${account.id}) has valid window: ${windowStart.toISOString()} - ${windowEnd.toISOString()} (${timeUntilExpires} minutes remaining)`); + validWindowCount++ + logger.info( + `✅ Account ${account.name} (${account.id}) has valid window: ${windowStart.toISOString()} - ${windowEnd.toISOString()} (${timeUntilExpires} minutes remaining)` + ) } else { // 窗口已过期,清除它 - expiredWindowCount++; - logger.warn(`⏰ Account ${account.name} (${account.id}) window expired: ${windowStart.toISOString()} - ${windowEnd.toISOString()}`); - + expiredWindowCount++ + logger.warn( + `⏰ Account ${account.name} (${account.id}) window expired: ${windowStart.toISOString()} - ${windowEnd.toISOString()}` + ) + // 清除过期的窗口信息 - delete account.sessionWindowStart; - delete account.sessionWindowEnd; - delete account.lastRequestTime; - await redis.setClaudeAccount(account.id, account); + delete account.sessionWindowStart + delete account.sessionWindowEnd + delete account.lastRequestTime + await redis.setClaudeAccount(account.id, account) } } else { - noWindowCount++; - logger.info(`📭 Account ${account.name} (${account.id}) has no session window - will create on next request`); + noWindowCount++ + logger.info( + `📭 Account ${account.name} (${account.id}) has no session window - will create on next request` + ) } } - - logger.success('✅ Session window initialization completed:'); - logger.success(` 📊 Total accounts: ${accounts.length}`); - logger.success(` ✅ Valid windows: ${validWindowCount}`); - logger.success(` ⏰ Expired windows: ${expiredWindowCount}`); - logger.success(` 📭 No windows: ${noWindowCount}`); - + + logger.success('✅ Session window initialization completed:') + logger.success(` 📊 Total accounts: ${accounts.length}`) + logger.success(` ✅ Valid windows: ${validWindowCount}`) + logger.success(` ⏰ Expired windows: ${expiredWindowCount}`) + logger.success(` 📭 No windows: ${noWindowCount}`) + return { total: accounts.length, validWindows: validWindowCount, expiredWindows: expiredWindowCount, noWindows: noWindowCount - }; + } } catch (error) { - logger.error('❌ Failed to initialize session windows:', error); + logger.error('❌ Failed to initialize session windows:', error) return { total: 0, validWindows: 0, expiredWindows: 0, noWindows: 0, error: error.message - }; + } } } } -module.exports = new ClaudeAccountService(); \ No newline at end of file +module.exports = new ClaudeAccountService() diff --git a/src/services/claudeCodeHeadersService.js b/src/services/claudeCodeHeadersService.js index 5b370723..3153d03c 100644 --- a/src/services/claudeCodeHeadersService.js +++ b/src/services/claudeCodeHeadersService.js @@ -3,8 +3,8 @@ * 负责存储和管理不同账号使用的 Claude Code headers */ -const redis = require('../models/redis'); -const logger = require('../utils/logger'); +const redis = require('../models/redis') +const logger = require('../utils/logger') class ClaudeCodeHeadersService { constructor() { @@ -22,8 +22,8 @@ class ClaudeCodeHeadersService { 'user-agent': 'claude-cli/1.0.57 (external, cli)', 'accept-language': '*', 'sec-fetch-mode': 'cors' - }; - + } + // 需要捕获的 Claude Code 特定 headers this.claudeCodeHeaderKeys = [ 'x-stainless-retry-count', @@ -40,16 +40,18 @@ class ClaudeCodeHeadersService { 'accept-language', 'sec-fetch-mode', 'accept-encoding' - ]; + ] } /** * 从 user-agent 中提取版本号 */ extractVersionFromUserAgent(userAgent) { - if (!userAgent) return null; - const match = userAgent.match(/claude-cli\/(\d+\.\d+\.\d+)/); - return match ? match[1] : null; + if (!userAgent) { + return null + } + const match = userAgent.match(/claude-cli\/(\d+\.\d+\.\d+)/) + return match ? match[1] : null } /** @@ -57,43 +59,49 @@ class ClaudeCodeHeadersService { * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal */ compareVersions(v1, v2) { - if (!v1 || !v2) return 0; - - const parts1 = v1.split('.').map(Number); - const parts2 = v2.split('.').map(Number); - - for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { - const p1 = parts1[i] || 0; - const p2 = parts2[i] || 0; - - if (p1 > p2) return 1; - if (p1 < p2) return -1; + if (!v1 || !v2) { + return 0 } - - return 0; + + const parts1 = v1.split('.').map(Number) + const parts2 = v2.split('.').map(Number) + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0 + const p2 = parts2[i] || 0 + + if (p1 > p2) { + return 1 + } + if (p1 < p2) { + return -1 + } + } + + return 0 } /** * 从客户端 headers 中提取 Claude Code 相关的 headers */ extractClaudeCodeHeaders(clientHeaders) { - const headers = {}; - + const headers = {} + // 转换所有 header keys 为小写进行比较 - const lowerCaseHeaders = {}; - Object.keys(clientHeaders || {}).forEach(key => { - lowerCaseHeaders[key.toLowerCase()] = clientHeaders[key]; - }); - + const lowerCaseHeaders = {} + Object.keys(clientHeaders || {}).forEach((key) => { + lowerCaseHeaders[key.toLowerCase()] = clientHeaders[key] + }) + // 提取需要的 headers - this.claudeCodeHeaderKeys.forEach(key => { - const lowerKey = key.toLowerCase(); + this.claudeCodeHeaderKeys.forEach((key) => { + const lowerKey = key.toLowerCase() if (lowerCaseHeaders[lowerKey]) { - headers[key] = lowerCaseHeaders[lowerKey]; + headers[key] = lowerCaseHeaders[lowerKey] } - }); - - return headers; + }) + + return headers } /** @@ -101,48 +109,47 @@ class ClaudeCodeHeadersService { */ async storeAccountHeaders(accountId, clientHeaders) { try { - const extractedHeaders = this.extractClaudeCodeHeaders(clientHeaders); - + const extractedHeaders = this.extractClaudeCodeHeaders(clientHeaders) + // 检查是否有 user-agent - const userAgent = extractedHeaders['user-agent']; + const userAgent = extractedHeaders['user-agent'] if (!userAgent || !userAgent.includes('claude-cli')) { // 不是 Claude Code 的请求,不存储 - return; + return } - - const version = this.extractVersionFromUserAgent(userAgent); + + const version = this.extractVersionFromUserAgent(userAgent) if (!version) { - logger.warn(`⚠️ Failed to extract version from user-agent: ${userAgent}`); - return; + logger.warn(`⚠️ Failed to extract version from user-agent: ${userAgent}`) + return } - + // 获取当前存储的 headers - const key = `claude_code_headers:${accountId}`; - const currentData = await redis.getClient().get(key); - + const key = `claude_code_headers:${accountId}` + const currentData = await redis.getClient().get(key) + if (currentData) { - const current = JSON.parse(currentData); - const currentVersion = this.extractVersionFromUserAgent(current.headers['user-agent']); - + const current = JSON.parse(currentData) + const currentVersion = this.extractVersionFromUserAgent(current.headers['user-agent']) + // 只有新版本更高时才更新 if (this.compareVersions(version, currentVersion) <= 0) { - return; + return } } - + // 存储新的 headers const data = { headers: extractedHeaders, - version: version, + version, updatedAt: new Date().toISOString() - }; - - await redis.getClient().setex(key, 86400 * 7, JSON.stringify(data)); // 7天过期 - - logger.info(`✅ Stored Claude Code headers for account ${accountId}, version: ${version}`); - + } + + await redis.getClient().setex(key, 86400 * 7, JSON.stringify(data)) // 7天过期 + + logger.info(`✅ Stored Claude Code headers for account ${accountId}, version: ${version}`) } catch (error) { - logger.error(`❌ Failed to store Claude Code headers for account ${accountId}:`, error); + logger.error(`❌ Failed to store Claude Code headers for account ${accountId}:`, error) } } @@ -151,22 +158,23 @@ class ClaudeCodeHeadersService { */ async getAccountHeaders(accountId) { try { - const key = `claude_code_headers:${accountId}`; - const data = await redis.getClient().get(key); - + const key = `claude_code_headers:${accountId}` + const data = await redis.getClient().get(key) + if (data) { - const parsed = JSON.parse(data); - logger.debug(`📋 Retrieved Claude Code headers for account ${accountId}, version: ${parsed.version}`); - return parsed.headers; + const parsed = JSON.parse(data) + logger.debug( + `📋 Retrieved Claude Code headers for account ${accountId}, version: ${parsed.version}` + ) + return parsed.headers } - + // 返回默认 headers - logger.debug(`📋 Using default Claude Code headers for account ${accountId}`); - return this.defaultHeaders; - + logger.debug(`📋 Using default Claude Code headers for account ${accountId}`) + return this.defaultHeaders } catch (error) { - logger.error(`❌ Failed to get Claude Code headers for account ${accountId}:`, error); - return this.defaultHeaders; + logger.error(`❌ Failed to get Claude Code headers for account ${accountId}:`, error) + return this.defaultHeaders } } @@ -175,11 +183,11 @@ class ClaudeCodeHeadersService { */ async clearAccountHeaders(accountId) { try { - const key = `claude_code_headers:${accountId}`; - await redis.getClient().del(key); - logger.info(`🗑️ Cleared Claude Code headers for account ${accountId}`); + const key = `claude_code_headers:${accountId}` + await redis.getClient().del(key) + logger.info(`🗑️ Cleared Claude Code headers for account ${accountId}`) } catch (error) { - logger.error(`❌ Failed to clear Claude Code headers for account ${accountId}:`, error); + logger.error(`❌ Failed to clear Claude Code headers for account ${accountId}:`, error) } } @@ -188,25 +196,24 @@ class ClaudeCodeHeadersService { */ async getAllAccountHeaders() { try { - const pattern = 'claude_code_headers:*'; - const keys = await redis.getClient().keys(pattern); - - const results = {}; + const pattern = 'claude_code_headers:*' + const keys = await redis.getClient().keys(pattern) + + const results = {} for (const key of keys) { - const accountId = key.replace('claude_code_headers:', ''); - const data = await redis.getClient().get(key); + const accountId = key.replace('claude_code_headers:', '') + const data = await redis.getClient().get(key) if (data) { - results[accountId] = JSON.parse(data); + results[accountId] = JSON.parse(data) } } - - return results; - + + return results } catch (error) { - logger.error('❌ Failed to get all account headers:', error); - return {}; + logger.error('❌ Failed to get all account headers:', error) + return {} } } } -module.exports = new ClaudeCodeHeadersService(); \ No newline at end of file +module.exports = new ClaudeCodeHeadersService() diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index c78a89bc..6581b22c 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -1,20 +1,20 @@ -const { v4: uuidv4 } = require('uuid'); -const crypto = require('crypto'); -const { SocksProxyAgent } = require('socks-proxy-agent'); -const { HttpsProxyAgent } = require('https-proxy-agent'); -const redis = require('../models/redis'); -const logger = require('../utils/logger'); -const config = require('../../config/config'); +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const { SocksProxyAgent } = require('socks-proxy-agent') +const { HttpsProxyAgent } = require('https-proxy-agent') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const config = require('../../config/config') class ClaudeConsoleAccountService { constructor() { // 加密相关常量 - this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'; - this.ENCRYPTION_SALT = 'claude-console-salt'; - + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'claude-console-salt' + // Redis键前缀 - this.ACCOUNT_KEY_PREFIX = 'claude_console_account:'; - this.SHARED_ACCOUNTS_KEY = 'shared_claude_console_accounts'; + this.ACCOUNT_KEY_PREFIX = 'claude_console_account:' + this.SHARED_ACCOUNTS_KEY = 'shared_claude_console_accounts' } // 🏢 创建Claude Console账户 @@ -32,24 +32,24 @@ class ClaudeConsoleAccountService { isActive = true, accountType = 'shared', // 'dedicated' or 'shared' schedulable = true // 是否可被调度 - } = options; + } = options // 验证必填字段 if (!apiUrl || !apiKey) { - throw new Error('API URL and API Key are required for Claude Console account'); + throw new Error('API URL and API Key are required for Claude Console account') } - const accountId = uuidv4(); - + const accountId = uuidv4() + // 处理 supportedModels,确保向后兼容 - const processedModels = this._processModelMapping(supportedModels); - + const processedModels = this._processModelMapping(supportedModels) + const accountData = { id: accountId, platform: 'claude-console', name, description, - apiUrl: apiUrl, + apiUrl, apiKey: this._encryptSensitiveData(apiKey), priority: priority.toString(), supportedModels: JSON.stringify(processedModels), @@ -67,24 +67,23 @@ class ClaudeConsoleAccountService { rateLimitStatus: '', // 调度控制 schedulable: schedulable.toString() - }; + } + + const client = redis.getClientSafe() + logger.debug( + `[DEBUG] Saving account data to Redis with key: ${this.ACCOUNT_KEY_PREFIX}${accountId}` + ) + logger.debug(`[DEBUG] Account data to save: ${JSON.stringify(accountData, null, 2)}`) + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData) - const client = redis.getClientSafe(); - logger.debug(`[DEBUG] Saving account data to Redis with key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`); - logger.debug(`[DEBUG] Account data to save: ${JSON.stringify(accountData, null, 2)}`); - - await client.hset( - `${this.ACCOUNT_KEY_PREFIX}${accountId}`, - accountData - ); - // 如果是共享账户,添加到共享账户集合 if (accountType === 'shared') { - await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId); + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) } - - logger.success(`🏢 Created Claude Console account: ${name} (${accountId})`); - + + logger.success(`🏢 Created Claude Console account: ${name} (${accountId})`) + return { id: accountId, name, @@ -99,22 +98,22 @@ class ClaudeConsoleAccountService { accountType, status: 'active', createdAt: accountData.createdAt - }; + } } // 📋 获取所有Claude Console账户 async getAllAccounts() { try { - const client = redis.getClientSafe(); - const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`); - const accounts = []; - + const client = redis.getClientSafe() + const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`) + const accounts = [] + for (const key of keys) { - const accountData = await client.hgetall(key); + const accountData = await client.hgetall(key) if (accountData && Object.keys(accountData).length > 0) { // 获取限流状态信息 - const rateLimitInfo = this._getRateLimitInfo(accountData); - + const rateLimitInfo = this._getRateLimitInfo(accountData) + accounts.push({ id: accountData.id, platform: accountData.platform, @@ -134,356 +133,379 @@ class ClaudeConsoleAccountService { lastUsedAt: accountData.lastUsedAt, rateLimitStatus: rateLimitInfo, schedulable: accountData.schedulable !== 'false' // 默认为true,只有明确设置为false才不可调度 - }); + }) } } - - return accounts; + + return accounts } catch (error) { - logger.error('❌ Failed to get Claude Console accounts:', error); - throw error; + logger.error('❌ Failed to get Claude Console accounts:', error) + throw error } } // 🔍 获取单个账户(内部使用,包含敏感信息) async getAccount(accountId) { - const client = redis.getClientSafe(); - logger.debug(`[DEBUG] Getting account data for ID: ${accountId}`); - const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`); - + const client = redis.getClientSafe() + logger.debug(`[DEBUG] Getting account data for ID: ${accountId}`) + const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) + if (!accountData || Object.keys(accountData).length === 0) { - logger.debug(`[DEBUG] No account data found for ID: ${accountId}`); - return null; + logger.debug(`[DEBUG] No account data found for ID: ${accountId}`) + return null } - - logger.debug(`[DEBUG] Raw account data keys: ${Object.keys(accountData).join(', ')}`); - logger.debug(`[DEBUG] Raw supportedModels value: ${accountData.supportedModels}`); - + + logger.debug(`[DEBUG] Raw account data keys: ${Object.keys(accountData).join(', ')}`) + logger.debug(`[DEBUG] Raw supportedModels value: ${accountData.supportedModels}`) + // 解密敏感字段(只解密apiKey,apiUrl不加密) - const decryptedKey = this._decryptSensitiveData(accountData.apiKey); - logger.debug(`[DEBUG] URL exists: ${!!accountData.apiUrl}, Decrypted key exists: ${!!decryptedKey}`); - - accountData.apiKey = decryptedKey; - + const decryptedKey = this._decryptSensitiveData(accountData.apiKey) + logger.debug( + `[DEBUG] URL exists: ${!!accountData.apiUrl}, Decrypted key exists: ${!!decryptedKey}` + ) + + accountData.apiKey = decryptedKey + // 解析JSON字段 - const parsedModels = JSON.parse(accountData.supportedModels || '[]'); - logger.debug(`[DEBUG] Parsed supportedModels: ${JSON.stringify(parsedModels)}`); - - accountData.supportedModels = parsedModels; - accountData.priority = parseInt(accountData.priority) || 50; - accountData.rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60; - accountData.isActive = accountData.isActive === 'true'; - accountData.schedulable = accountData.schedulable !== 'false'; // 默认为true - + const parsedModels = JSON.parse(accountData.supportedModels || '[]') + logger.debug(`[DEBUG] Parsed supportedModels: ${JSON.stringify(parsedModels)}`) + + accountData.supportedModels = parsedModels + accountData.priority = parseInt(accountData.priority) || 50 + accountData.rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60 + accountData.isActive = accountData.isActive === 'true' + accountData.schedulable = accountData.schedulable !== 'false' // 默认为true + if (accountData.proxy) { - accountData.proxy = JSON.parse(accountData.proxy); + accountData.proxy = JSON.parse(accountData.proxy) } - - logger.debug(`[DEBUG] Final account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`); - - return accountData; + + logger.debug( + `[DEBUG] Final account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}` + ) + + return accountData } // 📝 更新账户 async updateAccount(accountId, updates) { try { - const existingAccount = await this.getAccount(accountId); + const existingAccount = await this.getAccount(accountId) if (!existingAccount) { - throw new Error('Account not found'); + throw new Error('Account not found') } - const client = redis.getClientSafe(); - const updatedData = {}; + const client = redis.getClientSafe() + const updatedData = {} // 处理各个字段的更新 - logger.debug(`[DEBUG] Update request received with fields: ${Object.keys(updates).join(', ')}`); - logger.debug(`[DEBUG] Updates content: ${JSON.stringify(updates, null, 2)}`); - - if (updates.name !== undefined) updatedData.name = updates.name; - if (updates.description !== undefined) updatedData.description = updates.description; + logger.debug( + `[DEBUG] Update request received with fields: ${Object.keys(updates).join(', ')}` + ) + logger.debug(`[DEBUG] Updates content: ${JSON.stringify(updates, null, 2)}`) + + if (updates.name !== undefined) { + updatedData.name = updates.name + } + if (updates.description !== undefined) { + updatedData.description = updates.description + } if (updates.apiUrl !== undefined) { - logger.debug(`[DEBUG] Updating apiUrl from frontend: ${updates.apiUrl}`); - updatedData.apiUrl = updates.apiUrl; + logger.debug(`[DEBUG] Updating apiUrl from frontend: ${updates.apiUrl}`) + updatedData.apiUrl = updates.apiUrl } if (updates.apiKey !== undefined) { - logger.debug(`[DEBUG] Updating apiKey (length: ${updates.apiKey?.length})`); - updatedData.apiKey = this._encryptSensitiveData(updates.apiKey); + logger.debug(`[DEBUG] Updating apiKey (length: ${updates.apiKey?.length})`) + updatedData.apiKey = this._encryptSensitiveData(updates.apiKey) + } + if (updates.priority !== undefined) { + updatedData.priority = updates.priority.toString() } - if (updates.priority !== undefined) updatedData.priority = updates.priority.toString(); if (updates.supportedModels !== undefined) { - logger.debug(`[DEBUG] Updating supportedModels: ${JSON.stringify(updates.supportedModels)}`); + logger.debug(`[DEBUG] Updating supportedModels: ${JSON.stringify(updates.supportedModels)}`) // 处理 supportedModels,确保向后兼容 - const processedModels = this._processModelMapping(updates.supportedModels); - updatedData.supportedModels = JSON.stringify(processedModels); + const processedModels = this._processModelMapping(updates.supportedModels) + updatedData.supportedModels = JSON.stringify(processedModels) + } + if (updates.userAgent !== undefined) { + updatedData.userAgent = updates.userAgent + } + if (updates.rateLimitDuration !== undefined) { + updatedData.rateLimitDuration = updates.rateLimitDuration.toString() + } + if (updates.proxy !== undefined) { + updatedData.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' + } + if (updates.isActive !== undefined) { + updatedData.isActive = updates.isActive.toString() + } + if (updates.schedulable !== undefined) { + updatedData.schedulable = updates.schedulable.toString() } - if (updates.userAgent !== undefined) updatedData.userAgent = updates.userAgent; - if (updates.rateLimitDuration !== undefined) updatedData.rateLimitDuration = updates.rateLimitDuration.toString(); - if (updates.proxy !== undefined) updatedData.proxy = updates.proxy ? JSON.stringify(updates.proxy) : ''; - if (updates.isActive !== undefined) updatedData.isActive = updates.isActive.toString(); - if (updates.schedulable !== undefined) updatedData.schedulable = updates.schedulable.toString(); // 处理账户类型变更 if (updates.accountType && updates.accountType !== existingAccount.accountType) { - updatedData.accountType = updates.accountType; - + updatedData.accountType = updates.accountType + if (updates.accountType === 'shared') { - await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId); + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) } else { - await client.srem(this.SHARED_ACCOUNTS_KEY, accountId); + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) } } - updatedData.updatedAt = new Date().toISOString(); - - logger.debug(`[DEBUG] Final updatedData to save: ${JSON.stringify(updatedData, null, 2)}`); - logger.debug(`[DEBUG] Updating Redis key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`); - - await client.hset( - `${this.ACCOUNT_KEY_PREFIX}${accountId}`, - updatedData - ); - - logger.success(`📝 Updated Claude Console account: ${accountId}`); - - return { success: true }; + updatedData.updatedAt = new Date().toISOString() + + logger.debug(`[DEBUG] Final updatedData to save: ${JSON.stringify(updatedData, null, 2)}`) + logger.debug(`[DEBUG] Updating Redis key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`) + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData) + + logger.success(`📝 Updated Claude Console account: ${accountId}`) + + return { success: true } } catch (error) { - logger.error('❌ Failed to update Claude Console account:', error); - throw error; + logger.error('❌ Failed to update Claude Console account:', error) + throw error } } // 🗑️ 删除账户 async deleteAccount(accountId) { try { - const client = redis.getClientSafe(); - const account = await this.getAccount(accountId); - + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + if (!account) { - throw new Error('Account not found'); + throw new Error('Account not found') } - + // 从Redis删除 - await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`); - + await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) + // 从共享账户集合中移除 if (account.accountType === 'shared') { - await client.srem(this.SHARED_ACCOUNTS_KEY, accountId); + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) } - - logger.success(`🗑️ Deleted Claude Console account: ${accountId}`); - - return { success: true }; + + logger.success(`🗑️ Deleted Claude Console account: ${accountId}`) + + return { success: true } } catch (error) { - logger.error('❌ Failed to delete Claude Console account:', error); - throw error; + logger.error('❌ Failed to delete Claude Console account:', error) + throw error } } - // 🚫 标记账号为限流状态 async markAccountRateLimited(accountId) { try { - const client = redis.getClientSafe(); - const account = await this.getAccount(accountId); - + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + if (!account) { - throw new Error('Account not found'); + throw new Error('Account not found') } const updates = { rateLimitedAt: new Date().toISOString(), rateLimitStatus: 'limited' - }; + } - await client.hset( - `${this.ACCOUNT_KEY_PREFIX}${accountId}`, - updates - ); + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) - logger.warn(`🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})`); - return { success: true }; + logger.warn( + `🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})` + ) + return { success: true } } catch (error) { - logger.error(`❌ Failed to mark Claude Console account as rate limited: ${accountId}`, error); - throw error; + logger.error(`❌ Failed to mark Claude Console account as rate limited: ${accountId}`, error) + throw error } } // ✅ 移除账号的限流状态 async removeAccountRateLimit(accountId) { try { - const client = redis.getClientSafe(); - + const client = redis.getClientSafe() + await client.hdel( `${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'rateLimitedAt', 'rateLimitStatus' - ); + ) - logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`); - return { success: true }; + logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`) + return { success: true } } catch (error) { - logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error); - throw error; + logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error) + throw error } } // 🔍 检查账号是否处于限流状态 async isAccountRateLimited(accountId) { try { - const account = await this.getAccount(accountId); + const account = await this.getAccount(accountId) if (!account) { - return false; + return false } if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { - const rateLimitedAt = new Date(account.rateLimitedAt); - const now = new Date(); - const minutesSinceRateLimit = (now - rateLimitedAt) / (1000 * 60); + const rateLimitedAt = new Date(account.rateLimitedAt) + const now = new Date() + const minutesSinceRateLimit = (now - rateLimitedAt) / (1000 * 60) // 使用账户配置的限流时间 - const rateLimitDuration = account.rateLimitDuration || 60; - + const rateLimitDuration = account.rateLimitDuration || 60 + if (minutesSinceRateLimit >= rateLimitDuration) { - await this.removeAccountRateLimit(accountId); - return false; + await this.removeAccountRateLimit(accountId) + return false } - return true; + return true } - return false; + return false } catch (error) { - logger.error(`❌ Failed to check rate limit status for Claude Console account: ${accountId}`, error); - return false; + logger.error( + `❌ Failed to check rate limit status for Claude Console account: ${accountId}`, + error + ) + return false } } // 🚫 标记账号为封锁状态(模型不支持等原因) async blockAccount(accountId, reason) { try { - const client = redis.getClientSafe(); - + const client = redis.getClientSafe() + const updates = { status: 'blocked', errorMessage: reason, blockedAt: new Date().toISOString() - }; + } - await client.hset( - `${this.ACCOUNT_KEY_PREFIX}${accountId}`, - updates - ); + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) - logger.warn(`🚫 Claude Console account blocked: ${accountId} - ${reason}`); - return { success: true }; + logger.warn(`🚫 Claude Console account blocked: ${accountId} - ${reason}`) + return { success: true } } catch (error) { - logger.error(`❌ Failed to block Claude Console account: ${accountId}`, error); - throw error; + logger.error(`❌ Failed to block Claude Console account: ${accountId}`, error) + throw error } } // 🌐 创建代理agent _createProxyAgent(proxyConfig) { if (!proxyConfig) { - return null; + return null } try { - const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig; - + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig + if (proxy.type === 'socks5') { - const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''; - const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`; - return new SocksProxyAgent(socksUrl); + const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' + const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}` + return new SocksProxyAgent(socksUrl) } else if (proxy.type === 'http' || proxy.type === 'https') { - const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''; - const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`; - return new HttpsProxyAgent(httpUrl); + const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' + const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}` + return new HttpsProxyAgent(httpUrl) } } catch (error) { - logger.warn('⚠️ Invalid proxy configuration:', error); + logger.warn('⚠️ Invalid proxy configuration:', error) } - return null; + return null } // 🔐 加密敏感数据 _encryptSensitiveData(data) { - if (!data) return ''; - + if (!data) { + return '' + } + try { - const key = this._generateEncryptionKey(); - const iv = crypto.randomBytes(16); - - const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv); - let encrypted = cipher.update(data, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - - return iv.toString('hex') + ':' + encrypted; + const key = this._generateEncryptionKey() + const iv = crypto.randomBytes(16) + + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let encrypted = cipher.update(data, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return `${iv.toString('hex')}:${encrypted}` } catch (error) { - logger.error('❌ Encryption error:', error); - return data; + logger.error('❌ Encryption error:', error) + return data } } // 🔓 解密敏感数据 _decryptSensitiveData(encryptedData) { - if (!encryptedData) return ''; - + if (!encryptedData) { + return '' + } + try { if (encryptedData.includes(':')) { - const parts = encryptedData.split(':'); + const parts = encryptedData.split(':') if (parts.length === 2) { - const key = this._generateEncryptionKey(); - const iv = Buffer.from(parts[0], 'hex'); - const encrypted = parts[1]; - - const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv); - let decrypted = decipher.update(encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; + const key = this._generateEncryptionKey() + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted } } - - return encryptedData; + + return encryptedData } catch (error) { - logger.error('❌ Decryption error:', error); - return encryptedData; + logger.error('❌ Decryption error:', error) + return encryptedData } } // 🔑 生成加密密钥 _generateEncryptionKey() { - return crypto.scryptSync(config.security.encryptionKey, this.ENCRYPTION_SALT, 32); + return crypto.scryptSync(config.security.encryptionKey, this.ENCRYPTION_SALT, 32) } // 🎭 掩码API URL _maskApiUrl(apiUrl) { - if (!apiUrl) return ''; - + if (!apiUrl) { + return '' + } + try { - const url = new URL(apiUrl); - return `${url.protocol}//${url.hostname}/***`; + const url = new URL(apiUrl) + return `${url.protocol}//${url.hostname}/***` } catch { - return '***'; + return '***' } } // 📊 获取限流信息 _getRateLimitInfo(accountData) { if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) { - const rateLimitedAt = new Date(accountData.rateLimitedAt); - const now = new Date(); - const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60)); - const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60; - const minutesRemaining = Math.max(0, rateLimitDuration - minutesSinceRateLimit); + const rateLimitedAt = new Date(accountData.rateLimitedAt) + const now = new Date() + const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60)) + const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60 + const minutesRemaining = Math.max(0, rateLimitDuration - minutesSinceRateLimit) return { isRateLimited: minutesRemaining > 0, rateLimitedAt: accountData.rateLimitedAt, minutesSinceRateLimit, minutesRemaining - }; + } } return { @@ -491,57 +513,57 @@ class ClaudeConsoleAccountService { rateLimitedAt: null, minutesSinceRateLimit: 0, minutesRemaining: 0 - }; + } } // 🔄 处理模型映射,确保向后兼容 _processModelMapping(supportedModels) { // 如果是空值,返回空对象(支持所有模型) if (!supportedModels || (Array.isArray(supportedModels) && supportedModels.length === 0)) { - return {}; + return {} } // 如果已经是对象格式(新的映射表格式),直接返回 if (typeof supportedModels === 'object' && !Array.isArray(supportedModels)) { - return supportedModels; + return supportedModels } // 如果是数组格式(旧格式),转换为映射表 if (Array.isArray(supportedModels)) { - const mapping = {}; - supportedModels.forEach(model => { + const mapping = {} + supportedModels.forEach((model) => { if (model && typeof model === 'string') { - mapping[model] = model; // 映射到自身 + mapping[model] = model // 映射到自身 } - }); - return mapping; + }) + return mapping } // 其他情况返回空对象 - return {}; + return {} } // 🔍 检查模型是否支持(用于调度) isModelSupported(modelMapping, requestedModel) { // 如果映射表为空,支持所有模型 if (!modelMapping || Object.keys(modelMapping).length === 0) { - return true; + return true } // 检查请求的模型是否在映射表的键中 - return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel); + return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel) } // 🔄 获取映射后的模型名称 getMappedModel(modelMapping, requestedModel) { // 如果映射表为空,返回原模型 if (!modelMapping || Object.keys(modelMapping).length === 0) { - return requestedModel; + return requestedModel } // 返回映射后的模型,如果不存在则返回原模型 - return modelMapping[requestedModel] || requestedModel; + return modelMapping[requestedModel] || requestedModel } } -module.exports = new ClaudeConsoleAccountService(); \ No newline at end of file +module.exports = new ClaudeConsoleAccountService() diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index 6acec739..ec54e287 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -1,37 +1,54 @@ -const axios = require('axios'); -const claudeConsoleAccountService = require('./claudeConsoleAccountService'); -const logger = require('../utils/logger'); -const config = require('../../config/config'); +const axios = require('axios') +const claudeConsoleAccountService = require('./claudeConsoleAccountService') +const logger = require('../utils/logger') +const config = require('../../config/config') class ClaudeConsoleRelayService { constructor() { - this.defaultUserAgent = 'claude-cli/1.0.69 (external, cli)'; + this.defaultUserAgent = 'claude-cli/1.0.69 (external, cli)' } // 🚀 转发请求到Claude Console API - async relayRequest(requestBody, apiKeyData, clientRequest, clientResponse, clientHeaders, accountId, options = {}) { - let abortController = null; - + async relayRequest( + requestBody, + apiKeyData, + clientRequest, + clientResponse, + clientHeaders, + accountId, + options = {} + ) { + let abortController = null + try { // 获取账户信息 - const account = await claudeConsoleAccountService.getAccount(accountId); + const account = await claudeConsoleAccountService.getAccount(accountId) if (!account) { - throw new Error('Claude Console Claude account not found'); + throw new Error('Claude Console Claude account not found') } - logger.info(`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`); - logger.debug(`🌐 Account API URL: ${account.apiUrl}`); - logger.debug(`🔍 Account supportedModels: ${JSON.stringify(account.supportedModels)}`); - logger.debug(`🔑 Account has apiKey: ${!!account.apiKey}`); - logger.debug(`📝 Request model: ${requestBody.model}`); + logger.info( + `📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})` + ) + logger.debug(`🌐 Account API URL: ${account.apiUrl}`) + logger.debug(`🔍 Account supportedModels: ${JSON.stringify(account.supportedModels)}`) + logger.debug(`🔑 Account has apiKey: ${!!account.apiKey}`) + logger.debug(`📝 Request model: ${requestBody.model}`) // 处理模型映射 - let mappedModel = requestBody.model; - if (account.supportedModels && typeof account.supportedModels === 'object' && !Array.isArray(account.supportedModels)) { - const newModel = claudeConsoleAccountService.getMappedModel(account.supportedModels, requestBody.model); + let mappedModel = requestBody.model + if ( + account.supportedModels && + typeof account.supportedModels === 'object' && + !Array.isArray(account.supportedModels) + ) { + const newModel = claudeConsoleAccountService.getMappedModel( + account.supportedModels, + requestBody.model + ) if (newModel !== requestBody.model) { - logger.info(`🔄 Mapping model from ${requestBody.model} to ${newModel}`); - mappedModel = newModel; + logger.info(`🔄 Mapping model from ${requestBody.model} to ${newModel}`) + mappedModel = newModel } } @@ -39,52 +56,51 @@ class ClaudeConsoleRelayService { const modifiedRequestBody = { ...requestBody, model: mappedModel - }; + } // 模型兼容性检查已经在调度器中完成,这里不需要再检查 // 创建代理agent - const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy); + const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy) // 创建AbortController用于取消请求 - abortController = new AbortController(); + abortController = new AbortController() // 设置客户端断开监听器 const handleClientDisconnect = () => { - logger.info('🔌 Client disconnected, aborting Claude Console Claude request'); + logger.info('🔌 Client disconnected, aborting Claude Console Claude request') if (abortController && !abortController.signal.aborted) { - abortController.abort(); + abortController.abort() } - }; - + } + // 监听客户端断开事件 if (clientRequest) { - clientRequest.once('close', handleClientDisconnect); + clientRequest.once('close', handleClientDisconnect) } if (clientResponse) { - clientResponse.once('close', handleClientDisconnect); + clientResponse.once('close', handleClientDisconnect) } // 构建完整的API URL - const cleanUrl = account.apiUrl.replace(/\/$/, ''); // 移除末尾斜杠 - const apiEndpoint = cleanUrl.endsWith('/v1/messages') - ? cleanUrl - : `${cleanUrl}/v1/messages`; - - logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`); - logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`); - logger.debug(`[DEBUG] Client headers received: ${JSON.stringify(clientHeaders)}`); - + const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠 + const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` + + logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`) + logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`) + logger.debug(`[DEBUG] Client headers received: ${JSON.stringify(clientHeaders)}`) + // 过滤客户端请求头 - const filteredHeaders = this._filterClientHeaders(clientHeaders); - logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`); - + const filteredHeaders = this._filterClientHeaders(clientHeaders) + logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`) + // 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值 - const userAgent = account.userAgent || - clientHeaders?.['user-agent'] || - clientHeaders?.['User-Agent'] || - this.defaultUserAgent; - + const userAgent = + account.userAgent || + clientHeaders?.['user-agent'] || + clientHeaders?.['User-Agent'] || + this.defaultUserAgent + // 准备请求配置 const requestConfig = { method: 'POST', @@ -100,103 +116,123 @@ class ClaudeConsoleRelayService { timeout: config.proxy.timeout || 60000, signal: abortController.signal, validateStatus: () => true // 接受所有状态码 - }; + } // 根据 API Key 格式选择认证方式 if (account.apiKey && account.apiKey.startsWith('sk-ant-')) { // Anthropic 官方 API Key 使用 x-api-key - requestConfig.headers['x-api-key'] = account.apiKey; - logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key'); + requestConfig.headers['x-api-key'] = account.apiKey + logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key') } else { // 其他 API Key 使用 Authorization Bearer - requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`; - logger.debug('[DEBUG] Using Authorization Bearer authentication'); + requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}` + logger.debug('[DEBUG] Using Authorization Bearer authentication') } - - logger.debug(`[DEBUG] Initial headers before beta: ${JSON.stringify(requestConfig.headers, null, 2)}`); - + + logger.debug( + `[DEBUG] Initial headers before beta: ${JSON.stringify(requestConfig.headers, null, 2)}` + ) + // 添加beta header如果需要 if (options.betaHeader) { - logger.debug(`[DEBUG] Adding beta header: ${options.betaHeader}`); - requestConfig.headers['anthropic-beta'] = options.betaHeader; + logger.debug(`[DEBUG] Adding beta header: ${options.betaHeader}`) + requestConfig.headers['anthropic-beta'] = options.betaHeader } else { - logger.debug('[DEBUG] No beta header to add'); + logger.debug('[DEBUG] No beta header to add') } // 发送请求 - logger.debug('📤 Sending request to Claude Console API with headers:', JSON.stringify(requestConfig.headers, null, 2)); - const response = await axios(requestConfig); + logger.debug( + '📤 Sending request to Claude Console API with headers:', + JSON.stringify(requestConfig.headers, null, 2) + ) + const response = await axios(requestConfig) // 移除监听器(请求成功完成) if (clientRequest) { - clientRequest.removeListener('close', handleClientDisconnect); + clientRequest.removeListener('close', handleClientDisconnect) } if (clientResponse) { - clientResponse.removeListener('close', handleClientDisconnect); + clientResponse.removeListener('close', handleClientDisconnect) } - logger.debug(`🔗 Claude Console API response: ${response.status}`); - logger.debug(`[DEBUG] Response headers: ${JSON.stringify(response.headers)}`); - logger.debug(`[DEBUG] Response data type: ${typeof response.data}`); - logger.debug(`[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}`); - logger.debug(`[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}`); + logger.debug(`🔗 Claude Console API response: ${response.status}`) + logger.debug(`[DEBUG] Response headers: ${JSON.stringify(response.headers)}`) + logger.debug(`[DEBUG] Response data type: ${typeof response.data}`) + logger.debug( + `[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}` + ) + logger.debug( + `[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}` + ) // 检查是否为限流错误 if (response.status === 429) { - logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`); - await claudeConsoleAccountService.markAccountRateLimited(accountId); + logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`) + await claudeConsoleAccountService.markAccountRateLimited(accountId) } else if (response.status === 200 || response.status === 201) { // 如果请求成功,检查并移除限流状态 - const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId); + const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId) if (isRateLimited) { - await claudeConsoleAccountService.removeAccountRateLimit(accountId); + await claudeConsoleAccountService.removeAccountRateLimit(accountId) } } // 更新最后使用时间 - await this._updateLastUsedTime(accountId); + await this._updateLastUsedTime(accountId) - const responseBody = typeof response.data === 'string' ? response.data : JSON.stringify(response.data); - logger.debug(`[DEBUG] Final response body to return: ${responseBody}`); + const responseBody = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + logger.debug(`[DEBUG] Final response body to return: ${responseBody}`) return { statusCode: response.status, headers: response.headers, body: responseBody, accountId - }; - + } } catch (error) { // 处理特定错误 if (error.name === 'AbortError' || error.code === 'ECONNABORTED') { - logger.info('Request aborted due to client disconnect'); - throw new Error('Client disconnected'); + logger.info('Request aborted due to client disconnect') + throw new Error('Client disconnected') } - logger.error('❌ Claude Console Claude relay request failed:', error.message); - + logger.error('❌ Claude Console Claude relay request failed:', error.message) + // 不再因为模型不支持而block账号 - throw error; + throw error } } // 🌊 处理流式响应 - async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, clientHeaders, usageCallback, accountId, streamTransformer = null, options = {}) { + async relayStreamRequestWithUsageCapture( + requestBody, + apiKeyData, + responseStream, + clientHeaders, + usageCallback, + accountId, + streamTransformer = null, + options = {} + ) { try { // 获取账户信息 - const account = await claudeConsoleAccountService.getAccount(accountId); + const account = await claudeConsoleAccountService.getAccount(accountId) if (!account) { - throw new Error('Claude Console Claude account not found'); + throw new Error('Claude Console Claude account not found') } - logger.info(`📡 Processing streaming Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`); - logger.debug(`🌐 Account API URL: ${account.apiUrl}`); + logger.info( + `📡 Processing streaming Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})` + ) + logger.debug(`🌐 Account API URL: ${account.apiUrl}`) // 模型兼容性检查已经在调度器中完成,这里不需要再检查 // 创建代理agent - const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy); + const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy) // 发送流式请求 await this._makeClaudeConsoleStreamRequest( @@ -209,40 +245,48 @@ class ClaudeConsoleRelayService { usageCallback, streamTransformer, options - ); + ) // 更新最后使用时间 - await this._updateLastUsedTime(accountId); - + await this._updateLastUsedTime(accountId) } catch (error) { - logger.error('❌ Claude Console Claude stream relay failed:', error); - throw error; + logger.error('❌ Claude Console Claude stream relay failed:', error) + throw error } } // 🌊 发送流式请求到Claude Console API - async _makeClaudeConsoleStreamRequest(body, account, proxyAgent, clientHeaders, responseStream, accountId, usageCallback, streamTransformer = null, requestOptions = {}) { + async _makeClaudeConsoleStreamRequest( + body, + account, + proxyAgent, + clientHeaders, + responseStream, + accountId, + usageCallback, + streamTransformer = null, + requestOptions = {} + ) { return new Promise((resolve, reject) => { - let aborted = false; + let aborted = false // 构建完整的API URL - const cleanUrl = account.apiUrl.replace(/\/$/, ''); // 移除末尾斜杠 - const apiEndpoint = cleanUrl.endsWith('/v1/messages') - ? cleanUrl - : `${cleanUrl}/v1/messages`; - - logger.debug(`🎯 Final API endpoint for stream: ${apiEndpoint}`); + const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠 + const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` + + logger.debug(`🎯 Final API endpoint for stream: ${apiEndpoint}`) // 过滤客户端请求头 - const filteredHeaders = this._filterClientHeaders(clientHeaders); - logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`); - + const filteredHeaders = this._filterClientHeaders(clientHeaders) + logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`) + // 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值 - const userAgent = account.userAgent || - clientHeaders?.['user-agent'] || - clientHeaders?.['User-Agent'] || - this.defaultUserAgent; - + const userAgent = + account.userAgent || + clientHeaders?.['user-agent'] || + clientHeaders?.['User-Agent'] || + this.defaultUserAgent + // 准备请求配置 const requestConfig = { method: 'POST', @@ -258,237 +302,254 @@ class ClaudeConsoleRelayService { timeout: config.proxy.timeout || 60000, responseType: 'stream', validateStatus: () => true // 接受所有状态码 - }; + } // 根据 API Key 格式选择认证方式 if (account.apiKey && account.apiKey.startsWith('sk-ant-')) { // Anthropic 官方 API Key 使用 x-api-key - requestConfig.headers['x-api-key'] = account.apiKey; - logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key'); + requestConfig.headers['x-api-key'] = account.apiKey + logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key') } else { // 其他 API Key 使用 Authorization Bearer - requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`; - logger.debug('[DEBUG] Using Authorization Bearer authentication'); + requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}` + logger.debug('[DEBUG] Using Authorization Bearer authentication') } - + // 添加beta header如果需要 if (requestOptions.betaHeader) { - requestConfig.headers['anthropic-beta'] = requestOptions.betaHeader; + requestConfig.headers['anthropic-beta'] = requestOptions.betaHeader } // 发送请求 - const request = axios(requestConfig); + const request = axios(requestConfig) - request.then(response => { - logger.debug(`🌊 Claude Console Claude stream response status: ${response.status}`); + request + .then((response) => { + logger.debug(`🌊 Claude Console Claude stream response status: ${response.status}`) - // 错误响应处理 - if (response.status !== 200) { - logger.error(`❌ Claude Console API returned error status: ${response.status}`); - - if (response.status === 429) { - claudeConsoleAccountService.markAccountRateLimited(accountId); + // 错误响应处理 + if (response.status !== 200) { + logger.error(`❌ Claude Console API returned error status: ${response.status}`) + + if (response.status === 429) { + claudeConsoleAccountService.markAccountRateLimited(accountId) + } + + // 设置错误响应的状态码和响应头 + if (!responseStream.headersSent) { + const errorHeaders = { + 'Content-Type': response.headers['content-type'] || 'application/json', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + } + // 避免 Transfer-Encoding 冲突,让 Express 自动处理 + delete errorHeaders['Transfer-Encoding'] + delete errorHeaders['Content-Length'] + responseStream.writeHead(response.status, errorHeaders) + } + + // 直接透传错误数据,不进行包装 + response.data.on('data', (chunk) => { + if (!responseStream.destroyed) { + responseStream.write(chunk) + } + }) + + response.data.on('end', () => { + if (!responseStream.destroyed) { + responseStream.end() + } + resolve() // 不抛出异常,正常完成流处理 + }) + return } - // 设置错误响应的状态码和响应头 + // 成功响应,检查并移除限流状态 + claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => { + if (isRateLimited) { + claudeConsoleAccountService.removeAccountRateLimit(accountId) + } + }) + + // 设置响应头 if (!responseStream.headersSent) { - const errorHeaders = { - 'Content-Type': response.headers['content-type'] || 'application/json', + responseStream.writeHead(200, { + 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - }; - // 避免 Transfer-Encoding 冲突,让 Express 自动处理 - delete errorHeaders['Transfer-Encoding']; - delete errorHeaders['Content-Length']; - responseStream.writeHead(response.status, errorHeaders); + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }) } - // 直接透传错误数据,不进行包装 - response.data.on('data', chunk => { - if (!responseStream.destroyed) { - responseStream.write(chunk); - } - }); + let buffer = '' + let finalUsageReported = false + const collectedUsageData = {} - response.data.on('end', () => { - if (!responseStream.destroyed) { - responseStream.end(); - } - resolve(); // 不抛出异常,正常完成流处理 - }); - return; - } - - // 成功响应,检查并移除限流状态 - claudeConsoleAccountService.isAccountRateLimited(accountId).then(isRateLimited => { - if (isRateLimited) { - claudeConsoleAccountService.removeAccountRateLimit(accountId); - } - }); - - // 设置响应头 - if (!responseStream.headersSent) { - responseStream.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no' - }); - } - - let buffer = ''; - let finalUsageReported = false; - let collectedUsageData = {}; - - // 处理流数据 - response.data.on('data', chunk => { - try { - if (aborted) return; - - const chunkStr = chunk.toString(); - buffer += chunkStr; - - // 处理完整的SSE行 - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - // 转发数据并解析usage - if (lines.length > 0 && !responseStream.destroyed) { - const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : ''); - - // 应用流转换器如果有 - if (streamTransformer) { - const transformed = streamTransformer(linesToForward); - if (transformed) { - responseStream.write(transformed); - } - } else { - responseStream.write(linesToForward); + // 处理流数据 + response.data.on('data', (chunk) => { + try { + if (aborted) { + return } - // 解析SSE数据寻找usage信息 - for (const line of lines) { - if (line.startsWith('data: ') && line.length > 6) { - try { - const jsonStr = line.slice(6); - const data = JSON.parse(jsonStr); - - // 收集usage数据 - if (data.type === 'message_start' && data.message && data.message.usage) { - collectedUsageData.input_tokens = data.message.usage.input_tokens || 0; - collectedUsageData.cache_creation_input_tokens = data.message.usage.cache_creation_input_tokens || 0; - collectedUsageData.cache_read_input_tokens = data.message.usage.cache_read_input_tokens || 0; - collectedUsageData.model = data.message.model; - } - - if (data.type === 'message_delta' && data.usage && data.usage.output_tokens !== undefined) { - collectedUsageData.output_tokens = data.usage.output_tokens || 0; - - if (collectedUsageData.input_tokens !== undefined && !finalUsageReported) { - usageCallback({ ...collectedUsageData, accountId }); - finalUsageReported = true; - } - } + const chunkStr = chunk.toString() + buffer += chunkStr - // 不再因为模型不支持而block账号 - } catch (e) { - // 忽略解析错误 + // 处理完整的SSE行 + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + // 转发数据并解析usage + if (lines.length > 0 && !responseStream.destroyed) { + const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '') + + // 应用流转换器如果有 + if (streamTransformer) { + const transformed = streamTransformer(linesToForward) + if (transformed) { + responseStream.write(transformed) + } + } else { + responseStream.write(linesToForward) + } + + // 解析SSE数据寻找usage信息 + for (const line of lines) { + if (line.startsWith('data: ') && line.length > 6) { + try { + const jsonStr = line.slice(6) + const data = JSON.parse(jsonStr) + + // 收集usage数据 + if (data.type === 'message_start' && data.message && data.message.usage) { + collectedUsageData.input_tokens = data.message.usage.input_tokens || 0 + collectedUsageData.cache_creation_input_tokens = + data.message.usage.cache_creation_input_tokens || 0 + collectedUsageData.cache_read_input_tokens = + data.message.usage.cache_read_input_tokens || 0 + collectedUsageData.model = data.message.model + } + + if ( + data.type === 'message_delta' && + data.usage && + data.usage.output_tokens !== undefined + ) { + collectedUsageData.output_tokens = data.usage.output_tokens || 0 + + if (collectedUsageData.input_tokens !== undefined && !finalUsageReported) { + usageCallback({ ...collectedUsageData, accountId }) + finalUsageReported = true + } + } + + // 不再因为模型不支持而block账号 + } catch (e) { + // 忽略解析错误 + } } } } - } - } catch (error) { - logger.error('❌ Error processing Claude Console stream data:', error); - if (!responseStream.destroyed) { - responseStream.write('event: error\n'); - responseStream.write(`data: ${JSON.stringify({ - error: 'Stream processing error', - message: error.message, - timestamp: new Date().toISOString() - })}\n\n`); - } - } - }); - - response.data.on('end', () => { - try { - // 处理缓冲区中剩余的数据 - if (buffer.trim() && !responseStream.destroyed) { - if (streamTransformer) { - const transformed = streamTransformer(buffer); - if (transformed) { - responseStream.write(transformed); - } - } else { - responseStream.write(buffer); + } catch (error) { + logger.error('❌ Error processing Claude Console stream data:', error) + if (!responseStream.destroyed) { + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Stream processing error', + message: error.message, + timestamp: new Date().toISOString() + })}\n\n` + ) } } - - // 确保流正确结束 - if (!responseStream.destroyed) { - responseStream.end(); + }) + + response.data.on('end', () => { + try { + // 处理缓冲区中剩余的数据 + if (buffer.trim() && !responseStream.destroyed) { + if (streamTransformer) { + const transformed = streamTransformer(buffer) + if (transformed) { + responseStream.write(transformed) + } + } else { + responseStream.write(buffer) + } + } + + // 确保流正确结束 + if (!responseStream.destroyed) { + responseStream.end() + } + + logger.debug('🌊 Claude Console Claude stream response completed') + resolve() + } catch (error) { + logger.error('❌ Error processing stream end:', error) + reject(error) } + }) - logger.debug('🌊 Claude Console Claude stream response completed'); - resolve(); - } catch (error) { - logger.error('❌ Error processing stream end:', error); - reject(error); + response.data.on('error', (error) => { + logger.error('❌ Claude Console stream error:', error) + if (!responseStream.destroyed) { + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Stream error', + message: error.message, + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() + } + reject(error) + }) + }) + .catch((error) => { + if (aborted) { + return + } + + logger.error('❌ Claude Console Claude stream request error:', error.message) + + // 检查是否是429错误 + if (error.response && error.response.status === 429) { + claudeConsoleAccountService.markAccountRateLimited(accountId) + } + + // 发送错误响应 + if (!responseStream.headersSent) { + responseStream.writeHead(error.response?.status || 500, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + }) } - }); - response.data.on('error', error => { - logger.error('❌ Claude Console stream error:', error); if (!responseStream.destroyed) { - responseStream.write('event: error\n'); - responseStream.write(`data: ${JSON.stringify({ - error: 'Stream error', - message: error.message, - timestamp: new Date().toISOString() - })}\n\n`); - responseStream.end(); + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: error.message, + code: error.code, + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() } - reject(error); - }); - }).catch(error => { - if (aborted) return; - - logger.error('❌ Claude Console Claude stream request error:', error.message); - - // 检查是否是429错误 - if (error.response && error.response.status === 429) { - claudeConsoleAccountService.markAccountRateLimited(accountId); - } - - // 发送错误响应 - if (!responseStream.headersSent) { - responseStream.writeHead(error.response?.status || 500, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - }); - } - - if (!responseStream.destroyed) { - responseStream.write('event: error\n'); - responseStream.write(`data: ${JSON.stringify({ - error: error.message, - code: error.code, - timestamp: new Date().toISOString() - })}\n\n`); - responseStream.end(); - } - - reject(error); - }); + reject(error) + }) // 处理客户端断开连接 responseStream.on('close', () => { - logger.debug('🔌 Client disconnected, cleaning up Claude Console stream'); - aborted = true; - }); - }); + logger.debug('🔌 Client disconnected, cleaning up Claude Console stream') + aborted = true + }) + }) } // 🔧 过滤客户端请求头 @@ -505,55 +566,58 @@ class ClaudeConsoleRelayService { 'content-encoding', 'transfer-encoding', 'anthropic-version' - ]; - - const filteredHeaders = {}; - - Object.keys(clientHeaders || {}).forEach(key => { - const lowerKey = key.toLowerCase(); + ] + + const filteredHeaders = {} + + Object.keys(clientHeaders || {}).forEach((key) => { + const lowerKey = key.toLowerCase() if (!sensitiveHeaders.includes(lowerKey)) { - filteredHeaders[key] = clientHeaders[key]; + filteredHeaders[key] = clientHeaders[key] } - }); - - return filteredHeaders; + }) + + return filteredHeaders } // 🕐 更新最后使用时间 async _updateLastUsedTime(accountId) { try { - const client = require('../models/redis').getClientSafe(); + const client = require('../models/redis').getClientSafe() await client.hset( `claude_console_account:${accountId}`, 'lastUsedAt', new Date().toISOString() - ); + ) } catch (error) { - logger.warn(`⚠️ Failed to update last used time for Claude Console account ${accountId}:`, error.message); + logger.warn( + `⚠️ Failed to update last used time for Claude Console account ${accountId}:`, + error.message + ) } } // 🎯 健康检查 async healthCheck() { try { - const accounts = await claudeConsoleAccountService.getAllAccounts(); - const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active'); - + const accounts = await claudeConsoleAccountService.getAllAccounts() + const activeAccounts = accounts.filter((acc) => acc.isActive && acc.status === 'active') + return { healthy: activeAccounts.length > 0, activeAccounts: activeAccounts.length, totalAccounts: accounts.length, timestamp: new Date().toISOString() - }; + } } catch (error) { - logger.error('❌ Claude Console Claude health check failed:', error); + logger.error('❌ Claude Console Claude health check failed:', error) return { healthy: false, error: error.message, timestamp: new Date().toISOString() - }; + } } } } -module.exports = new ClaudeConsoleRelayService(); \ No newline at end of file +module.exports = new ClaudeConsoleRelayService() diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 76107006..6e8f1552 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -1,64 +1,75 @@ -const https = require('https'); -const zlib = require('zlib'); -const fs = require('fs'); -const path = require('path'); -const { SocksProxyAgent } = require('socks-proxy-agent'); -const { HttpsProxyAgent } = require('https-proxy-agent'); -const claudeAccountService = require('./claudeAccountService'); -const unifiedClaudeScheduler = require('./unifiedClaudeScheduler'); -const sessionHelper = require('../utils/sessionHelper'); -const logger = require('../utils/logger'); -const config = require('../../config/config'); -const claudeCodeHeadersService = require('./claudeCodeHeadersService'); +const https = require('https') +const zlib = require('zlib') +const fs = require('fs') +const path = require('path') +const { SocksProxyAgent } = require('socks-proxy-agent') +const { HttpsProxyAgent } = require('https-proxy-agent') +const claudeAccountService = require('./claudeAccountService') +const unifiedClaudeScheduler = require('./unifiedClaudeScheduler') +const sessionHelper = require('../utils/sessionHelper') +const logger = require('../utils/logger') +const config = require('../../config/config') +const claudeCodeHeadersService = require('./claudeCodeHeadersService') class ClaudeRelayService { constructor() { - this.claudeApiUrl = config.claude.apiUrl; - this.apiVersion = config.claude.apiVersion; - this.betaHeader = config.claude.betaHeader; - this.systemPrompt = config.claude.systemPrompt; - this.claudeCodeSystemPrompt = 'You are Claude Code, Anthropic\'s official CLI for Claude.'; + this.claudeApiUrl = config.claude.apiUrl + this.apiVersion = config.claude.apiVersion + this.betaHeader = config.claude.betaHeader + this.systemPrompt = config.claude.systemPrompt + this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." } // 🔍 判断是否是真实的 Claude Code 请求 isRealClaudeCodeRequest(requestBody, clientHeaders) { // 检查 user-agent 是否匹配 Claude Code 格式 - const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent'] || ''; - const isClaudeCodeUserAgent = /claude-cli\/\d+\.\d+\.\d+/.test(userAgent); - + const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent'] || '' + const isClaudeCodeUserAgent = /claude-cli\/\d+\.\d+\.\d+/.test(userAgent) + // 检查系统提示词是否包含 Claude Code 标识 - const hasClaudeCodeSystemPrompt = this._hasClaudeCodeSystemPrompt(requestBody); - + const hasClaudeCodeSystemPrompt = this._hasClaudeCodeSystemPrompt(requestBody) + // 只有当 user-agent 匹配且系统提示词正确时,才认为是真实的 Claude Code 请求 - return isClaudeCodeUserAgent && hasClaudeCodeSystemPrompt; + return isClaudeCodeUserAgent && hasClaudeCodeSystemPrompt } // 🔍 检查请求中是否包含 Claude Code 系统提示词 _hasClaudeCodeSystemPrompt(requestBody) { - if (!requestBody || !requestBody.system) return false; - + if (!requestBody || !requestBody.system) { + return false + } + // 如果是字符串格式,一定不是真实的 Claude Code 请求 if (typeof requestBody.system === 'string') { - return false; - } - + return false + } + // 处理数组格式 if (Array.isArray(requestBody.system) && requestBody.system.length > 0) { - const firstItem = requestBody.system[0]; + const firstItem = requestBody.system[0] // 检查第一个元素是否包含 Claude Code 提示词 - return firstItem && - firstItem.type === 'text' && - firstItem.text && - firstItem.text === this.claudeCodeSystemPrompt; + return ( + firstItem && + firstItem.type === 'text' && + firstItem.text && + firstItem.text === this.claudeCodeSystemPrompt + ) } - - return false; + + return false } // 🚀 转发请求到Claude API - async relayRequest(requestBody, apiKeyData, clientRequest, clientResponse, clientHeaders, options = {}) { - let upstreamRequest = null; - + async relayRequest( + requestBody, + apiKeyData, + clientRequest, + clientResponse, + clientHeaders, + options = {} + ) { + let upstreamRequest = null + try { // 调试日志:查看API Key数据 logger.info('🔍 API Key data received:', { @@ -66,15 +77,23 @@ class ClaudeRelayService { enableModelRestriction: apiKeyData.enableModelRestriction, restrictedModels: apiKeyData.restrictedModels, requestedModel: requestBody.model - }); + }) // 检查模型限制 - if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels && apiKeyData.restrictedModels.length > 0) { - const requestedModel = requestBody.model; - logger.info(`🔒 Model restriction check - Requested model: ${requestedModel}, Restricted models: ${JSON.stringify(apiKeyData.restrictedModels)}`); - + if ( + apiKeyData.enableModelRestriction && + apiKeyData.restrictedModels && + apiKeyData.restrictedModels.length > 0 + ) { + const requestedModel = requestBody.model + logger.info( + `🔒 Model restriction check - Requested model: ${requestedModel}, Restricted models: ${JSON.stringify(apiKeyData.restrictedModels)}` + ) + if (requestedModel && apiKeyData.restrictedModels.includes(requestedModel)) { - logger.warn(`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`); + logger.warn( + `🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}` + ) return { statusCode: 403, headers: { 'Content-Type': 'application/json' }, @@ -84,145 +103,188 @@ class ClaudeRelayService { message: '暂无该模型访问权限' } }) - }; + } } } - + // 生成会话哈希用于sticky会话 - const sessionHash = sessionHelper.generateSessionHash(requestBody); - + const sessionHash = sessionHelper.generateSessionHash(requestBody) + // 选择可用的Claude账户(支持专属绑定和sticky会话) - const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(apiKeyData, sessionHash, requestBody.model); - const accountId = accountSelection.accountId; - const accountType = accountSelection.accountType; - - logger.info(`📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}`); - + const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + requestBody.model + ) + const { accountId } = accountSelection + const { accountType } = accountSelection + + logger.info( + `📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}` + ) + // 获取有效的访问token - const accessToken = await claudeAccountService.getValidAccessToken(accountId); - + const accessToken = await claudeAccountService.getValidAccessToken(accountId) + // 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词) - const processedBody = this._processRequestBody(requestBody, clientHeaders); - + const processedBody = this._processRequestBody(requestBody, clientHeaders) + // 获取代理配置 - const proxyAgent = await this._getProxyAgent(accountId); - + const proxyAgent = await this._getProxyAgent(accountId) + // 设置客户端断开监听器 const handleClientDisconnect = () => { - logger.info('🔌 Client disconnected, aborting upstream request'); + logger.info('🔌 Client disconnected, aborting upstream request') if (upstreamRequest && !upstreamRequest.destroyed) { - upstreamRequest.destroy(); + upstreamRequest.destroy() } - }; - + } + // 监听客户端断开事件 if (clientRequest) { - clientRequest.once('close', handleClientDisconnect); + clientRequest.once('close', handleClientDisconnect) } if (clientResponse) { - clientResponse.once('close', handleClientDisconnect); + clientResponse.once('close', handleClientDisconnect) } - + // 发送请求到Claude API(传入回调以获取请求对象) const response = await this._makeClaudeRequest( - processedBody, - accessToken, + processedBody, + accessToken, proxyAgent, clientHeaders, accountId, - (req) => { upstreamRequest = req; }, + (req) => { + upstreamRequest = req + }, options - ); - + ) + // 移除监听器(请求成功完成) if (clientRequest) { - clientRequest.removeListener('close', handleClientDisconnect); + clientRequest.removeListener('close', handleClientDisconnect) } if (clientResponse) { - clientResponse.removeListener('close', handleClientDisconnect); + clientResponse.removeListener('close', handleClientDisconnect) } - + // 检查响应是否为限流错误 if (response.statusCode !== 200 && response.statusCode !== 201) { - let isRateLimited = false; - let rateLimitResetTimestamp = null; - + let isRateLimited = false + let rateLimitResetTimestamp = null + // 检查是否为429状态码 if (response.statusCode === 429) { - isRateLimited = true; - + isRateLimited = true + // 提取限流重置时间戳 if (response.headers && response.headers['anthropic-ratelimit-unified-reset']) { - rateLimitResetTimestamp = parseInt(response.headers['anthropic-ratelimit-unified-reset']); - logger.info(`🕐 Extracted rate limit reset timestamp: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})`); + rateLimitResetTimestamp = parseInt( + response.headers['anthropic-ratelimit-unified-reset'] + ) + logger.info( + `🕐 Extracted rate limit reset timestamp: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})` + ) } } else { // 检查响应体中的错误信息 try { - const responseBody = typeof response.body === 'string' ? JSON.parse(response.body) : response.body; - if (responseBody && responseBody.error && responseBody.error.message && - responseBody.error.message.toLowerCase().includes('exceed your account\'s rate limit')) { - isRateLimited = true; + const responseBody = + typeof response.body === 'string' ? JSON.parse(response.body) : response.body + if ( + responseBody && + responseBody.error && + responseBody.error.message && + responseBody.error.message.toLowerCase().includes("exceed your account's rate limit") + ) { + isRateLimited = true } } catch (e) { // 如果解析失败,检查原始字符串 - if (response.body && response.body.toLowerCase().includes('exceed your account\'s rate limit')) { - isRateLimited = true; + if ( + response.body && + response.body.toLowerCase().includes("exceed your account's rate limit") + ) { + isRateLimited = true } } } - + if (isRateLimited) { - logger.warn(`🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}`); + logger.warn( + `🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}` + ) // 标记账号为限流状态并删除粘性会话映射,传递准确的重置时间戳 - await unifiedClaudeScheduler.markAccountRateLimited(accountId, accountType, sessionHash, rateLimitResetTimestamp); + await unifiedClaudeScheduler.markAccountRateLimited( + accountId, + accountType, + sessionHash, + rateLimitResetTimestamp + ) } } else if (response.statusCode === 200 || response.statusCode === 201) { // 如果请求成功,检查并移除限流状态 - const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited(accountId, accountType); + const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited( + accountId, + accountType + ) if (isRateLimited) { - await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType); + await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType) } - + // 只有真实的 Claude Code 请求才更新 headers - if (clientHeaders && Object.keys(clientHeaders).length > 0 && this.isRealClaudeCodeRequest(requestBody, clientHeaders)) { - await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders); + if ( + clientHeaders && + Object.keys(clientHeaders).length > 0 && + this.isRealClaudeCodeRequest(requestBody, clientHeaders) + ) { + await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders) } } - + // 记录成功的API调用 - const inputTokens = requestBody.messages ? - requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4 : 0; // 粗略估算 - const outputTokens = response.content ? - response.content.reduce((sum, content) => sum + (content.text?.length || 0), 0) / 4 : 0; - - logger.info(`✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens, Output: ~${Math.round(outputTokens)} tokens`); - + const inputTokens = requestBody.messages + ? requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4 + : 0 // 粗略估算 + const outputTokens = response.content + ? response.content.reduce((sum, content) => sum + (content.text?.length || 0), 0) / 4 + : 0 + + logger.info( + `✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens, Output: ~${Math.round(outputTokens)} tokens` + ) + // 在响应中添加accountId,以便调用方记录账户级别统计 - response.accountId = accountId; - return response; + response.accountId = accountId + return response } catch (error) { - logger.error(`❌ Claude relay request failed for key: ${apiKeyData.name || apiKeyData.id}:`, error.message); - throw error; + logger.error( + `❌ Claude relay request failed for key: ${apiKeyData.name || apiKeyData.id}:`, + error.message + ) + throw error } } // 🔄 处理请求体 _processRequestBody(body, clientHeaders = {}) { - if (!body) return body; + if (!body) { + return body + } // 深拷贝请求体 - const processedBody = JSON.parse(JSON.stringify(body)); + const processedBody = JSON.parse(JSON.stringify(body)) // 验证并限制max_tokens参数 - this._validateAndLimitMaxTokens(processedBody); + this._validateAndLimitMaxTokens(processedBody) // 移除cache_control中的ttl字段 - this._stripTtlFromCacheControl(processedBody); + this._stripTtlFromCacheControl(processedBody) // 判断是否是真实的 Claude Code 请求 - const isRealClaudeCode = this.isRealClaudeCodeRequest(processedBody, clientHeaders); - + const isRealClaudeCode = this.isRealClaudeCodeRequest(processedBody, clientHeaders) + // 如果不是真实的 Claude Code 请求,需要设置 Claude Code 系统提示词 if (!isRealClaudeCode) { const claudeCodePrompt = { @@ -231,7 +293,7 @@ class ClaudeRelayService { cache_control: { type: 'ephemeral' } - }; + } if (processedBody.system) { if (typeof processedBody.system === 'string') { @@ -239,178 +301,185 @@ class ClaudeRelayService { const userSystemPrompt = { type: 'text', text: processedBody.system - }; + } // 如果用户的提示词与 Claude Code 提示词相同,只保留一个 if (processedBody.system.trim() === this.claudeCodeSystemPrompt) { - processedBody.system = [claudeCodePrompt]; + processedBody.system = [claudeCodePrompt] } else { - processedBody.system = [claudeCodePrompt, userSystemPrompt]; + processedBody.system = [claudeCodePrompt, userSystemPrompt] } } else if (Array.isArray(processedBody.system)) { // 检查第一个元素是否是 Claude Code 系统提示词 - const firstItem = processedBody.system[0]; - const isFirstItemClaudeCode = firstItem && - firstItem.type === 'text' && - firstItem.text === this.claudeCodeSystemPrompt; - + const firstItem = processedBody.system[0] + const isFirstItemClaudeCode = + firstItem && firstItem.type === 'text' && firstItem.text === this.claudeCodeSystemPrompt + if (!isFirstItemClaudeCode) { // 如果第一个不是 Claude Code 提示词,需要在开头插入 // 同时检查数组中是否有其他位置包含 Claude Code 提示词,如果有则移除 - const filteredSystem = processedBody.system.filter(item => - !(item && item.type === 'text' && item.text === this.claudeCodeSystemPrompt) - ); - processedBody.system = [claudeCodePrompt, ...filteredSystem]; + const filteredSystem = processedBody.system.filter( + (item) => !(item && item.type === 'text' && item.text === this.claudeCodeSystemPrompt) + ) + processedBody.system = [claudeCodePrompt, ...filteredSystem] } } else { // 其他格式,记录警告但不抛出错误,尝试处理 - logger.warn('⚠️ Unexpected system field type:', typeof processedBody.system); - processedBody.system = [claudeCodePrompt]; + logger.warn('⚠️ Unexpected system field type:', typeof processedBody.system) + processedBody.system = [claudeCodePrompt] } } else { // 用户没有传递 system,需要添加 Claude Code 提示词 - processedBody.system = [claudeCodePrompt]; + processedBody.system = [claudeCodePrompt] } } - + // 处理原有的系统提示(如果配置了) if (this.systemPrompt && this.systemPrompt.trim()) { const systemPrompt = { type: 'text', text: this.systemPrompt - }; + } // 经过上面的处理,system 现在应该总是数组格式 if (processedBody.system && Array.isArray(processedBody.system)) { // 不要重复添加相同的系统提示 - const hasSystemPrompt = processedBody.system.some(item => - item && item.text && item.text === this.systemPrompt - ); + const hasSystemPrompt = processedBody.system.some( + (item) => item && item.text && item.text === this.systemPrompt + ) if (!hasSystemPrompt) { - processedBody.system.push(systemPrompt); + processedBody.system.push(systemPrompt) } } else { // 理论上不应该走到这里,但为了安全起见 - processedBody.system = [systemPrompt]; + processedBody.system = [systemPrompt] } } else { // 如果没有配置系统提示,且system字段为空,则删除它 if (processedBody.system && Array.isArray(processedBody.system)) { - const hasValidContent = processedBody.system.some(item => - item && item.text && item.text.trim() - ); + const hasValidContent = processedBody.system.some( + (item) => item && item.text && item.text.trim() + ) if (!hasValidContent) { - delete processedBody.system; + delete processedBody.system } } } // Claude API只允许temperature或top_p其中之一,优先使用temperature if (processedBody.top_p !== undefined && processedBody.top_p !== null) { - delete processedBody.top_p; + delete processedBody.top_p } - return processedBody; + return processedBody } // 🔢 验证并限制max_tokens参数 _validateAndLimitMaxTokens(body) { - if (!body || !body.max_tokens) return; + if (!body || !body.max_tokens) { + return + } try { // 读取模型定价配置文件 - const pricingFilePath = path.join(__dirname, '../../data/model_pricing.json'); - + const pricingFilePath = path.join(__dirname, '../../data/model_pricing.json') + if (!fs.existsSync(pricingFilePath)) { - logger.warn('⚠️ Model pricing file not found, skipping max_tokens validation'); - return; + logger.warn('⚠️ Model pricing file not found, skipping max_tokens validation') + return } - const pricingData = JSON.parse(fs.readFileSync(pricingFilePath, 'utf8')); - const model = body.model || 'claude-sonnet-4-20250514'; - + const pricingData = JSON.parse(fs.readFileSync(pricingFilePath, 'utf8')) + const model = body.model || 'claude-sonnet-4-20250514' + // 查找对应模型的配置 - const modelConfig = pricingData[model]; - + const modelConfig = pricingData[model] + if (!modelConfig) { - logger.debug(`🔍 Model ${model} not found in pricing file, skipping max_tokens validation`); - return; + logger.debug(`🔍 Model ${model} not found in pricing file, skipping max_tokens validation`) + return } // 获取模型的最大token限制 - const maxLimit = modelConfig.max_tokens || modelConfig.max_output_tokens; - + const maxLimit = modelConfig.max_tokens || modelConfig.max_output_tokens + if (!maxLimit) { - logger.debug(`🔍 No max_tokens limit found for model ${model}, skipping validation`); - return; + logger.debug(`🔍 No max_tokens limit found for model ${model}, skipping validation`) + return } // 检查并调整max_tokens if (body.max_tokens > maxLimit) { - logger.warn(`⚠️ max_tokens ${body.max_tokens} exceeds limit ${maxLimit} for model ${model}, adjusting to ${maxLimit}`); - body.max_tokens = maxLimit; + logger.warn( + `⚠️ max_tokens ${body.max_tokens} exceeds limit ${maxLimit} for model ${model}, adjusting to ${maxLimit}` + ) + body.max_tokens = maxLimit } } catch (error) { - logger.error('❌ Failed to validate max_tokens from pricing file:', error); + logger.error('❌ Failed to validate max_tokens from pricing file:', error) // 如果文件读取失败,不进行校验,让请求继续处理 } } // 🧹 移除TTL字段 _stripTtlFromCacheControl(body) { - if (!body || typeof body !== 'object') return; + if (!body || typeof body !== 'object') { + return + } const processContentArray = (contentArray) => { - if (!Array.isArray(contentArray)) return; - - contentArray.forEach(item => { + if (!Array.isArray(contentArray)) { + return + } + + contentArray.forEach((item) => { if (item && typeof item === 'object' && item.cache_control) { if (item.cache_control.ttl) { - delete item.cache_control.ttl; - logger.debug('🧹 Removed ttl from cache_control'); + delete item.cache_control.ttl + logger.debug('🧹 Removed ttl from cache_control') } } - }); - }; + }) + } if (Array.isArray(body.system)) { - processContentArray(body.system); + processContentArray(body.system) } if (Array.isArray(body.messages)) { - body.messages.forEach(message => { + body.messages.forEach((message) => { if (message && Array.isArray(message.content)) { - processContentArray(message.content); + processContentArray(message.content) } - }); + }) } } // 🌐 获取代理Agent async _getProxyAgent(accountId) { try { - const accountData = await claudeAccountService.getAllAccounts(); - const account = accountData.find(acc => acc.id === accountId); - + const accountData = await claudeAccountService.getAllAccounts() + const account = accountData.find((acc) => acc.id === accountId) + if (!account || !account.proxy) { - return null; + return null } - const proxy = account.proxy; - + const { proxy } = account + if (proxy.type === 'socks5') { - const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''; - const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`; - return new SocksProxyAgent(socksUrl); + const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' + const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}` + return new SocksProxyAgent(socksUrl) } else if (proxy.type === 'http' || proxy.type === 'https') { - const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''; - const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`; - return new HttpsProxyAgent(httpUrl); + const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' + const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}` + return new HttpsProxyAgent(httpUrl) } } catch (error) { - logger.warn('⚠️ Failed to create proxy agent:', error); + logger.warn('⚠️ Failed to create proxy agent:', error) } - return null; + return null } // 🔧 过滤客户端请求头 @@ -427,59 +496,64 @@ class ClaudeRelayService { 'proxy-authorization', 'content-encoding', 'transfer-encoding' - ]; - + ] + // 应该保留的 headers(用于会话一致性和追踪) - const allowedHeaders = [ - 'x-request-id' - ]; - - const filteredHeaders = {}; - + const allowedHeaders = ['x-request-id'] + + const filteredHeaders = {} + // 转发客户端的非敏感 headers - Object.keys(clientHeaders || {}).forEach(key => { - const lowerKey = key.toLowerCase(); + Object.keys(clientHeaders || {}).forEach((key) => { + const lowerKey = key.toLowerCase() // 如果在允许列表中,直接保留 if (allowedHeaders.includes(lowerKey)) { - filteredHeaders[key] = clientHeaders[key]; - } + filteredHeaders[key] = clientHeaders[key] + } // 如果不在敏感列表中,也保留 else if (!sensitiveHeaders.includes(lowerKey)) { - filteredHeaders[key] = clientHeaders[key]; + filteredHeaders[key] = clientHeaders[key] } - }); - - return filteredHeaders; + }) + + return filteredHeaders } // 🔗 发送请求到Claude API - async _makeClaudeRequest(body, accessToken, proxyAgent, clientHeaders, accountId, onRequest, requestOptions = {}) { - const url = new URL(this.claudeApiUrl); - + async _makeClaudeRequest( + body, + accessToken, + proxyAgent, + clientHeaders, + accountId, + onRequest, + requestOptions = {} + ) { + const url = new URL(this.claudeApiUrl) + // 获取过滤后的客户端 headers - const filteredHeaders = this._filterClientHeaders(clientHeaders); - + const filteredHeaders = this._filterClientHeaders(clientHeaders) + // 判断是否是真实的 Claude Code 请求 - const isRealClaudeCode = this.isRealClaudeCodeRequest(body, clientHeaders); - + const isRealClaudeCode = this.isRealClaudeCodeRequest(body, clientHeaders) + // 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers - let finalHeaders = { ...filteredHeaders }; - + const finalHeaders = { ...filteredHeaders } + if (!isRealClaudeCode) { // 获取该账号存储的 Claude Code headers - const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId); - + const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) + // 只添加客户端没有提供的 headers - Object.keys(claudeCodeHeaders).forEach(key => { - const lowerKey = key.toLowerCase(); + Object.keys(claudeCodeHeaders).forEach((key) => { + const lowerKey = key.toLowerCase() if (!finalHeaders[key] && !finalHeaders[lowerKey]) { - finalHeaders[key] = claudeCodeHeaders[key]; + finalHeaders[key] = claudeCodeHeaders[key] } - }); + }) } - + return new Promise((resolve, reject) => { - const options = { hostname: url.hostname, port: url.port || 443, @@ -487,116 +561,125 @@ class ClaudeRelayService { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, + Authorization: `Bearer ${accessToken}`, 'anthropic-version': this.apiVersion, ...finalHeaders }, agent: proxyAgent, timeout: config.proxy.timeout - }; - + } + // 如果客户端没有提供 User-Agent,使用默认值 if (!options.headers['User-Agent'] && !options.headers['user-agent']) { - options.headers['User-Agent'] = 'claude-cli/1.0.57 (external, cli)'; + options.headers['User-Agent'] = 'claude-cli/1.0.57 (external, cli)' } // 使用自定义的 betaHeader 或默认值 - const betaHeader = requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader; + const betaHeader = + requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader if (betaHeader) { - options.headers['anthropic-beta'] = betaHeader; + options.headers['anthropic-beta'] = betaHeader } const req = https.request(options, (res) => { - let responseData = Buffer.alloc(0); - + let responseData = Buffer.alloc(0) + res.on('data', (chunk) => { - responseData = Buffer.concat([responseData, chunk]); - }); - + responseData = Buffer.concat([responseData, chunk]) + }) + res.on('end', () => { try { - let bodyString = ''; - + let bodyString = '' + // 根据Content-Encoding处理响应数据 - const contentEncoding = res.headers['content-encoding']; + const contentEncoding = res.headers['content-encoding'] if (contentEncoding === 'gzip') { try { - bodyString = zlib.gunzipSync(responseData).toString('utf8'); + bodyString = zlib.gunzipSync(responseData).toString('utf8') } catch (unzipError) { - logger.error('❌ Failed to decompress gzip response:', unzipError); - bodyString = responseData.toString('utf8'); + logger.error('❌ Failed to decompress gzip response:', unzipError) + bodyString = responseData.toString('utf8') } } else if (contentEncoding === 'deflate') { try { - bodyString = zlib.inflateSync(responseData).toString('utf8'); + bodyString = zlib.inflateSync(responseData).toString('utf8') } catch (unzipError) { - logger.error('❌ Failed to decompress deflate response:', unzipError); - bodyString = responseData.toString('utf8'); + logger.error('❌ Failed to decompress deflate response:', unzipError) + bodyString = responseData.toString('utf8') } } else { - bodyString = responseData.toString('utf8'); + bodyString = responseData.toString('utf8') } - + const response = { statusCode: res.statusCode, headers: res.headers, body: bodyString - }; - - logger.debug(`🔗 Claude API response: ${res.statusCode}`); - - resolve(response); + } + + logger.debug(`🔗 Claude API response: ${res.statusCode}`) + + resolve(response) } catch (error) { - logger.error('❌ Failed to parse Claude API response:', error); - reject(error); + logger.error('❌ Failed to parse Claude API response:', error) + reject(error) } - }); - }); - + }) + }) + // 如果提供了 onRequest 回调,传递请求对象 if (onRequest && typeof onRequest === 'function') { - onRequest(req); + onRequest(req) } req.on('error', (error) => { - console.error(': ❌ ', error); + console.error(': ❌ ', error) logger.error('❌ Claude API request error:', error.message, { code: error.code, errno: error.errno, syscall: error.syscall, address: error.address, port: error.port - }); - + }) + // 根据错误类型提供更具体的错误信息 - let errorMessage = 'Upstream request failed'; + let errorMessage = 'Upstream request failed' if (error.code === 'ECONNRESET') { - errorMessage = 'Connection reset by Claude API server'; + errorMessage = 'Connection reset by Claude API server' } else if (error.code === 'ENOTFOUND') { - errorMessage = 'Unable to resolve Claude API hostname'; + errorMessage = 'Unable to resolve Claude API hostname' } else if (error.code === 'ECONNREFUSED') { - errorMessage = 'Connection refused by Claude API server'; + errorMessage = 'Connection refused by Claude API server' } else if (error.code === 'ETIMEDOUT') { - errorMessage = 'Connection timed out to Claude API server'; + errorMessage = 'Connection timed out to Claude API server' } - - reject(new Error(errorMessage)); - }); + + reject(new Error(errorMessage)) + }) req.on('timeout', () => { - req.destroy(); - logger.error('❌ Claude API request timeout'); - reject(new Error('Request timeout')); - }); + req.destroy() + logger.error('❌ Claude API request timeout') + reject(new Error('Request timeout')) + }) // 写入请求体 - req.write(JSON.stringify(body)); - req.end(); - }); + req.write(JSON.stringify(body)) + req.end() + }) } // 🌊 处理流式响应(带usage数据捕获) - async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, clientHeaders, usageCallback, streamTransformer = null, options = {}) { + async relayStreamRequestWithUsageCapture( + requestBody, + apiKeyData, + responseStream, + clientHeaders, + usageCallback, + streamTransformer = null, + options = {} + ) { try { // 调试日志:查看API Key数据(流式请求) logger.info('🔍 [Stream] API Key data received:', { @@ -604,87 +687,125 @@ class ClaudeRelayService { enableModelRestriction: apiKeyData.enableModelRestriction, restrictedModels: apiKeyData.restrictedModels, requestedModel: requestBody.model - }); + }) // 检查模型限制 - if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels && apiKeyData.restrictedModels.length > 0) { - const requestedModel = requestBody.model; - logger.info(`🔒 [Stream] Model restriction check - Requested model: ${requestedModel}, Restricted models: ${JSON.stringify(apiKeyData.restrictedModels)}`); - + if ( + apiKeyData.enableModelRestriction && + apiKeyData.restrictedModels && + apiKeyData.restrictedModels.length > 0 + ) { + const requestedModel = requestBody.model + logger.info( + `🔒 [Stream] Model restriction check - Requested model: ${requestedModel}, Restricted models: ${JSON.stringify(apiKeyData.restrictedModels)}` + ) + if (requestedModel && apiKeyData.restrictedModels.includes(requestedModel)) { - logger.warn(`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`); - + logger.warn( + `🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}` + ) + // 对于流式响应,需要写入错误并结束流 const errorResponse = JSON.stringify({ error: { type: 'forbidden', message: '暂无该模型访问权限' } - }); - - responseStream.writeHead(403, { 'Content-Type': 'application/json' }); - responseStream.end(errorResponse); - return; + }) + + responseStream.writeHead(403, { 'Content-Type': 'application/json' }) + responseStream.end(errorResponse) + return } } - + // 生成会话哈希用于sticky会话 - const sessionHash = sessionHelper.generateSessionHash(requestBody); - + const sessionHash = sessionHelper.generateSessionHash(requestBody) + // 选择可用的Claude账户(支持专属绑定和sticky会话) - const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(apiKeyData, sessionHash, requestBody.model); - const accountId = accountSelection.accountId; - const accountType = accountSelection.accountType; - - logger.info(`📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}`); - + const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + requestBody.model + ) + const { accountId } = accountSelection + const { accountType } = accountSelection + + logger.info( + `📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}` + ) + // 获取有效的访问token - const accessToken = await claudeAccountService.getValidAccessToken(accountId); - + const accessToken = await claudeAccountService.getValidAccessToken(accountId) + // 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词) - const processedBody = this._processRequestBody(requestBody, clientHeaders); - + const processedBody = this._processRequestBody(requestBody, clientHeaders) + // 获取代理配置 - const proxyAgent = await this._getProxyAgent(accountId); - + const proxyAgent = await this._getProxyAgent(accountId) + // 发送流式请求并捕获usage数据 - return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, (usageData) => { - // 在usageCallback中添加accountId - usageCallback({ ...usageData, accountId }); - }, accountId, accountType, sessionHash, streamTransformer, options); + await this._makeClaudeStreamRequestWithUsageCapture( + processedBody, + accessToken, + proxyAgent, + clientHeaders, + responseStream, + (usageData) => { + // 在usageCallback中添加accountId + usageCallback({ ...usageData, accountId }) + }, + accountId, + accountType, + sessionHash, + streamTransformer, + options + ) } catch (error) { - logger.error('❌ Claude stream relay with usage capture failed:', error); - throw error; + logger.error('❌ Claude stream relay with usage capture failed:', error) + throw error } } // 🌊 发送流式请求到Claude API(带usage数据捕获) - async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, accountType, sessionHash, streamTransformer = null, requestOptions = {}) { + async _makeClaudeStreamRequestWithUsageCapture( + body, + accessToken, + proxyAgent, + clientHeaders, + responseStream, + usageCallback, + accountId, + accountType, + sessionHash, + streamTransformer = null, + requestOptions = {} + ) { // 获取过滤后的客户端 headers - const filteredHeaders = this._filterClientHeaders(clientHeaders); - + const filteredHeaders = this._filterClientHeaders(clientHeaders) + // 判断是否是真实的 Claude Code 请求 - const isRealClaudeCode = this.isRealClaudeCodeRequest(body, clientHeaders); - + const isRealClaudeCode = this.isRealClaudeCodeRequest(body, clientHeaders) + // 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers - let finalHeaders = { ...filteredHeaders }; - + const finalHeaders = { ...filteredHeaders } + if (!isRealClaudeCode) { // 获取该账号存储的 Claude Code headers - const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId); - + const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) + // 只添加客户端没有提供的 headers - Object.keys(claudeCodeHeaders).forEach(key => { - const lowerKey = key.toLowerCase(); + Object.keys(claudeCodeHeaders).forEach((key) => { + const lowerKey = key.toLowerCase() if (!finalHeaders[key] && !finalHeaders[lowerKey]) { - finalHeaders[key] = claudeCodeHeaders[key]; + finalHeaders[key] = claudeCodeHeaders[key] } - }); + }) } - + return new Promise((resolve, reject) => { - const url = new URL(this.claudeApiUrl); - + const url = new URL(this.claudeApiUrl) + const options = { hostname: url.hostname, port: url.port || 443, @@ -692,291 +813,338 @@ class ClaudeRelayService { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, + Authorization: `Bearer ${accessToken}`, 'anthropic-version': this.apiVersion, ...finalHeaders }, agent: proxyAgent, timeout: config.proxy.timeout - }; - + } + // 如果客户端没有提供 User-Agent,使用默认值 if (!options.headers['User-Agent'] && !options.headers['user-agent']) { - options.headers['User-Agent'] = 'claude-cli/1.0.57 (external, cli)'; + options.headers['User-Agent'] = 'claude-cli/1.0.57 (external, cli)' } // 使用自定义的 betaHeader 或默认值 - const betaHeader = requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader; + const betaHeader = + requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader if (betaHeader) { - options.headers['anthropic-beta'] = betaHeader; + options.headers['anthropic-beta'] = betaHeader } const req = https.request(options, (res) => { - logger.debug(`🌊 Claude stream response status: ${res.statusCode}`); + logger.debug(`🌊 Claude stream response status: ${res.statusCode}`) // 错误响应处理 if (res.statusCode !== 200) { - logger.error(`❌ Claude API returned error status: ${res.statusCode}`); - let errorData = ''; - + logger.error(`❌ Claude API returned error status: ${res.statusCode}`) + let errorData = '' + res.on('data', (chunk) => { - errorData += chunk.toString(); - }); - + errorData += chunk.toString() + }) + res.on('end', () => { - console.error(': ❌ ', errorData); - logger.error('❌ Claude API error response:', errorData); + console.error(': ❌ ', errorData) + logger.error('❌ Claude API error response:', errorData) if (!responseStream.destroyed) { // 发送错误事件 - responseStream.write('event: error\n'); - responseStream.write(`data: ${JSON.stringify({ - error: 'Claude API error', - status: res.statusCode, - details: errorData, - timestamp: new Date().toISOString() - })}\n\n`); - responseStream.end(); + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Claude API error', + status: res.statusCode, + details: errorData, + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() } - reject(new Error(`Claude API error: ${res.statusCode}`)); - }); - return; + reject(new Error(`Claude API error: ${res.statusCode}`)) + }) + return } - let buffer = ''; - let finalUsageReported = false; // 防止重复统计的标志 - let collectedUsageData = {}; // 收集来自不同事件的usage数据 - let rateLimitDetected = false; // 限流检测标志 - + let buffer = '' + let finalUsageReported = false // 防止重复统计的标志 + const collectedUsageData = {} // 收集来自不同事件的usage数据 + let rateLimitDetected = false // 限流检测标志 + // 监听数据块,解析SSE并寻找usage信息 res.on('data', (chunk) => { try { - const chunkStr = chunk.toString(); - - buffer += chunkStr; - + const chunkStr = chunk.toString() + + buffer += chunkStr + // 处理完整的SSE行 - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // 保留最后的不完整行 - + const lines = buffer.split('\n') + buffer = lines.pop() || '' // 保留最后的不完整行 + // 转发已处理的完整行到客户端 if (lines.length > 0 && !responseStream.destroyed) { - const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : ''); + const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '') // 如果有流转换器,应用转换 if (streamTransformer) { - const transformed = streamTransformer(linesToForward); + const transformed = streamTransformer(linesToForward) if (transformed) { - responseStream.write(transformed); + responseStream.write(transformed) } } else { - responseStream.write(linesToForward); + responseStream.write(linesToForward) } } - - for (const line of lines) { - // 解析SSE数据寻找usage信息 - if (line.startsWith('data: ') && line.length > 6) { - try { - const jsonStr = line.slice(6); - const data = JSON.parse(jsonStr); - - // 收集来自不同事件的usage数据 - if (data.type === 'message_start' && data.message && data.message.usage) { - // message_start包含input tokens、cache tokens和模型信息 - collectedUsageData.input_tokens = data.message.usage.input_tokens || 0; - collectedUsageData.cache_creation_input_tokens = data.message.usage.cache_creation_input_tokens || 0; - collectedUsageData.cache_read_input_tokens = data.message.usage.cache_read_input_tokens || 0; - collectedUsageData.model = data.message.model; - - logger.info('📊 Collected input/cache data from message_start:', JSON.stringify(collectedUsageData)); - } - - // message_delta包含最终的output tokens - if (data.type === 'message_delta' && data.usage && data.usage.output_tokens !== undefined) { - collectedUsageData.output_tokens = data.usage.output_tokens || 0; - - logger.info('📊 Collected output data from message_delta:', JSON.stringify(collectedUsageData)); - - // 如果已经收集到了input数据,现在有了output数据,可以统计了 - if (collectedUsageData.input_tokens !== undefined && !finalUsageReported) { - logger.info('🎯 Complete usage data collected, triggering callback'); - usageCallback(collectedUsageData); - finalUsageReported = true; + + for (const line of lines) { + // 解析SSE数据寻找usage信息 + if (line.startsWith('data: ') && line.length > 6) { + try { + const jsonStr = line.slice(6) + const data = JSON.parse(jsonStr) + + // 收集来自不同事件的usage数据 + if (data.type === 'message_start' && data.message && data.message.usage) { + // message_start包含input tokens、cache tokens和模型信息 + collectedUsageData.input_tokens = data.message.usage.input_tokens || 0 + collectedUsageData.cache_creation_input_tokens = + data.message.usage.cache_creation_input_tokens || 0 + collectedUsageData.cache_read_input_tokens = + data.message.usage.cache_read_input_tokens || 0 + collectedUsageData.model = data.message.model + + logger.info( + '📊 Collected input/cache data from message_start:', + JSON.stringify(collectedUsageData) + ) } + + // message_delta包含最终的output tokens + if ( + data.type === 'message_delta' && + data.usage && + data.usage.output_tokens !== undefined + ) { + collectedUsageData.output_tokens = data.usage.output_tokens || 0 + + logger.info( + '📊 Collected output data from message_delta:', + JSON.stringify(collectedUsageData) + ) + + // 如果已经收集到了input数据,现在有了output数据,可以统计了 + if (collectedUsageData.input_tokens !== undefined && !finalUsageReported) { + logger.info('🎯 Complete usage data collected, triggering callback') + usageCallback(collectedUsageData) + finalUsageReported = true + } + } + + // 检查是否有限流错误 + if ( + data.type === 'error' && + data.error && + data.error.message && + data.error.message.toLowerCase().includes("exceed your account's rate limit") + ) { + rateLimitDetected = true + logger.warn(`🚫 Rate limit detected in stream for account ${accountId}`) + } + } catch (parseError) { + // 忽略JSON解析错误,继续处理 + logger.debug('🔍 SSE line not JSON or no usage data:', line.slice(0, 100)) } - - // 检查是否有限流错误 - if (data.type === 'error' && data.error && data.error.message && - data.error.message.toLowerCase().includes('exceed your account\'s rate limit')) { - rateLimitDetected = true; - logger.warn(`🚫 Rate limit detected in stream for account ${accountId}`); - } - - } catch (parseError) { - // 忽略JSON解析错误,继续处理 - logger.debug('🔍 SSE line not JSON or no usage data:', line.slice(0, 100)); } } - } } catch (error) { - logger.error('❌ Error processing stream data:', error); + logger.error('❌ Error processing stream data:', error) // 发送错误但不破坏流,让它自然结束 if (!responseStream.destroyed) { - responseStream.write('event: error\n'); - responseStream.write(`data: ${JSON.stringify({ - error: 'Stream processing error', - message: error.message, - timestamp: new Date().toISOString() - })}\n\n`); + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Stream processing error', + message: error.message, + timestamp: new Date().toISOString() + })}\n\n` + ) } } - }); - + }) + res.on('end', async () => { try { // 处理缓冲区中剩余的数据 if (buffer.trim() && !responseStream.destroyed) { if (streamTransformer) { - const transformed = streamTransformer(buffer); + const transformed = streamTransformer(buffer) if (transformed) { - responseStream.write(transformed); + responseStream.write(transformed) } } else { - responseStream.write(buffer); + responseStream.write(buffer) } } - + // 确保流正确结束 if (!responseStream.destroyed) { - responseStream.end(); + responseStream.end() } } catch (error) { - logger.error('❌ Error processing stream end:', error); + logger.error('❌ Error processing stream end:', error) } - + // 检查是否捕获到usage数据 if (!finalUsageReported) { - logger.warn('⚠️ Stream completed but no usage data was captured! This indicates a problem with SSE parsing or Claude API response format.'); + logger.warn( + '⚠️ Stream completed but no usage data was captured! This indicates a problem with SSE parsing or Claude API response format.' + ) } - + // 处理限流状态 if (rateLimitDetected || res.statusCode === 429) { // 提取限流重置时间戳 - let rateLimitResetTimestamp = null; + let rateLimitResetTimestamp = null if (res.headers && res.headers['anthropic-ratelimit-unified-reset']) { - rateLimitResetTimestamp = parseInt(res.headers['anthropic-ratelimit-unified-reset']); - logger.info(`🕐 Extracted rate limit reset timestamp from stream: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})`); + rateLimitResetTimestamp = parseInt(res.headers['anthropic-ratelimit-unified-reset']) + logger.info( + `🕐 Extracted rate limit reset timestamp from stream: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})` + ) } - + // 标记账号为限流状态并删除粘性会话映射 - await unifiedClaudeScheduler.markAccountRateLimited(accountId, accountType, sessionHash, rateLimitResetTimestamp); + await unifiedClaudeScheduler.markAccountRateLimited( + accountId, + accountType, + sessionHash, + rateLimitResetTimestamp + ) } else if (res.statusCode === 200) { // 如果请求成功,检查并移除限流状态 - const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited(accountId, accountType); + const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited( + accountId, + accountType + ) if (isRateLimited) { - await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType); + await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType) } - + // 只有真实的 Claude Code 请求才更新 headers(流式请求) - if (clientHeaders && Object.keys(clientHeaders).length > 0 && this.isRealClaudeCodeRequest(body, clientHeaders)) { - await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders); + if ( + clientHeaders && + Object.keys(clientHeaders).length > 0 && + this.isRealClaudeCodeRequest(body, clientHeaders) + ) { + await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders) } } - - logger.debug('🌊 Claude stream response with usage capture completed'); - resolve(); - }); - }); + + logger.debug('🌊 Claude stream response with usage capture completed') + resolve() + }) + }) req.on('error', (error) => { logger.error('❌ Claude stream request error:', error.message, { code: error.code, errno: error.errno, syscall: error.syscall - }); - + }) + // 根据错误类型提供更具体的错误信息 - let errorMessage = 'Upstream request failed'; - let statusCode = 500; + let errorMessage = 'Upstream request failed' + let statusCode = 500 if (error.code === 'ECONNRESET') { - errorMessage = 'Connection reset by Claude API server'; - statusCode = 502; + errorMessage = 'Connection reset by Claude API server' + statusCode = 502 } else if (error.code === 'ENOTFOUND') { - errorMessage = 'Unable to resolve Claude API hostname'; - statusCode = 502; + errorMessage = 'Unable to resolve Claude API hostname' + statusCode = 502 } else if (error.code === 'ECONNREFUSED') { - errorMessage = 'Connection refused by Claude API server'; - statusCode = 502; + errorMessage = 'Connection refused by Claude API server' + statusCode = 502 } else if (error.code === 'ETIMEDOUT') { - errorMessage = 'Connection timed out to Claude API server'; - statusCode = 504; + errorMessage = 'Connection timed out to Claude API server' + statusCode = 504 } - + if (!responseStream.headersSent) { - responseStream.writeHead(statusCode, { + responseStream.writeHead(statusCode, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - }); + Connection: 'keep-alive' + }) } - + if (!responseStream.destroyed) { // 发送 SSE 错误事件 - responseStream.write('event: error\n'); - responseStream.write(`data: ${JSON.stringify({ - error: errorMessage, - code: error.code, - timestamp: new Date().toISOString() - })}\n\n`); - responseStream.end(); + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: errorMessage, + code: error.code, + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() } - reject(error); - }); + reject(error) + }) req.on('timeout', () => { - req.destroy(); - logger.error('❌ Claude stream request timeout'); + req.destroy() + logger.error('❌ Claude stream request timeout') if (!responseStream.headersSent) { - responseStream.writeHead(504, { + responseStream.writeHead(504, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - }); + Connection: 'keep-alive' + }) } if (!responseStream.destroyed) { // 发送 SSE 错误事件 - responseStream.write('event: error\n'); - responseStream.write(`data: ${JSON.stringify({ - error: 'Request timeout', - code: 'TIMEOUT', - timestamp: new Date().toISOString() - })}\n\n`); - responseStream.end(); + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Request timeout', + code: 'TIMEOUT', + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() } - reject(new Error('Request timeout')); - }); + reject(new Error('Request timeout')) + }) // 处理客户端断开连接 responseStream.on('close', () => { - logger.debug('🔌 Client disconnected, cleaning up stream'); + logger.debug('🔌 Client disconnected, cleaning up stream') if (!req.destroyed) { - req.destroy(); + req.destroy() } - }); + }) // 写入请求体 - req.write(JSON.stringify(body)); - req.end(); - }); + req.write(JSON.stringify(body)) + req.end() + }) } // 🌊 发送流式请求到Claude API - async _makeClaudeStreamRequest(body, accessToken, proxyAgent, clientHeaders, responseStream, requestOptions = {}) { + async _makeClaudeStreamRequest( + body, + accessToken, + proxyAgent, + clientHeaders, + responseStream, + requestOptions = {} + ) { return new Promise((resolve, reject) => { - const url = new URL(this.claudeApiUrl); - + const url = new URL(this.claudeApiUrl) + // 获取过滤后的客户端 headers - const filteredHeaders = this._filterClientHeaders(clientHeaders); - + const filteredHeaders = this._filterClientHeaders(clientHeaders) + const options = { hostname: url.hostname, port: url.port || 443, @@ -984,165 +1152,170 @@ class ClaudeRelayService { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, + Authorization: `Bearer ${accessToken}`, 'anthropic-version': this.apiVersion, ...filteredHeaders }, agent: proxyAgent, timeout: config.proxy.timeout - }; - + } + // 如果客户端没有提供 User-Agent,使用默认值 if (!filteredHeaders['User-Agent'] && !filteredHeaders['user-agent']) { - options.headers['User-Agent'] = 'claude-cli/1.0.53 (external, cli)'; + options.headers['User-Agent'] = 'claude-cli/1.0.53 (external, cli)' } // 使用自定义的 betaHeader 或默认值 - const betaHeader = requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader; + const betaHeader = + requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader if (betaHeader) { - options.headers['anthropic-beta'] = betaHeader; + options.headers['anthropic-beta'] = betaHeader } const req = https.request(options, (res) => { // 设置响应头 - responseStream.statusCode = res.statusCode; - Object.keys(res.headers).forEach(key => { - responseStream.setHeader(key, res.headers[key]); - }); + responseStream.statusCode = res.statusCode + Object.keys(res.headers).forEach((key) => { + responseStream.setHeader(key, res.headers[key]) + }) // 管道响应数据 - res.pipe(responseStream); - + res.pipe(responseStream) + res.on('end', () => { - logger.debug('🌊 Claude stream response completed'); - resolve(); - }); - }); + logger.debug('🌊 Claude stream response completed') + resolve() + }) + }) req.on('error', (error) => { logger.error('❌ Claude stream request error:', error.message, { code: error.code, errno: error.errno, syscall: error.syscall - }); - + }) + // 根据错误类型提供更具体的错误信息 - let errorMessage = 'Upstream request failed'; - let statusCode = 500; + let errorMessage = 'Upstream request failed' + let statusCode = 500 if (error.code === 'ECONNRESET') { - errorMessage = 'Connection reset by Claude API server'; - statusCode = 502; + errorMessage = 'Connection reset by Claude API server' + statusCode = 502 } else if (error.code === 'ENOTFOUND') { - errorMessage = 'Unable to resolve Claude API hostname'; - statusCode = 502; + errorMessage = 'Unable to resolve Claude API hostname' + statusCode = 502 } else if (error.code === 'ECONNREFUSED') { - errorMessage = 'Connection refused by Claude API server'; - statusCode = 502; + errorMessage = 'Connection refused by Claude API server' + statusCode = 502 } else if (error.code === 'ETIMEDOUT') { - errorMessage = 'Connection timed out to Claude API server'; - statusCode = 504; + errorMessage = 'Connection timed out to Claude API server' + statusCode = 504 } - + if (!responseStream.headersSent) { - responseStream.writeHead(statusCode, { + responseStream.writeHead(statusCode, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - }); + Connection: 'keep-alive' + }) } - + if (!responseStream.destroyed) { // 发送 SSE 错误事件 - responseStream.write('event: error\n'); - responseStream.write(`data: ${JSON.stringify({ - error: errorMessage, - code: error.code, - timestamp: new Date().toISOString() - })}\n\n`); - responseStream.end(); + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: errorMessage, + code: error.code, + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() } - reject(error); - }); + reject(error) + }) req.on('timeout', () => { - req.destroy(); - logger.error('❌ Claude stream request timeout'); + req.destroy() + logger.error('❌ Claude stream request timeout') if (!responseStream.headersSent) { - responseStream.writeHead(504, { + responseStream.writeHead(504, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - }); + Connection: 'keep-alive' + }) } if (!responseStream.destroyed) { // 发送 SSE 错误事件 - responseStream.write('event: error\n'); - responseStream.write(`data: ${JSON.stringify({ - error: 'Request timeout', - code: 'TIMEOUT', - timestamp: new Date().toISOString() - })}\n\n`); - responseStream.end(); + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Request timeout', + code: 'TIMEOUT', + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() } - reject(new Error('Request timeout')); - }); + reject(new Error('Request timeout')) + }) // 处理客户端断开连接 responseStream.on('close', () => { - logger.debug('🔌 Client disconnected, cleaning up stream'); + logger.debug('🔌 Client disconnected, cleaning up stream') if (!req.destroyed) { - req.destroy(); + req.destroy() } - }); + }) // 写入请求体 - req.write(JSON.stringify(body)); - req.end(); - }); + req.write(JSON.stringify(body)) + req.end() + }) } // 🔄 重试逻辑 async _retryRequest(requestFunc, maxRetries = 3) { - let lastError; - + let lastError + for (let i = 0; i < maxRetries; i++) { try { - return await requestFunc(); + return await requestFunc() } catch (error) { - lastError = error; - + lastError = error + if (i < maxRetries - 1) { - const delay = Math.pow(2, i) * 1000; // 指数退避 - logger.warn(`⏳ Retry ${i + 1}/${maxRetries} in ${delay}ms: ${error.message}`); - await new Promise(resolve => setTimeout(resolve, delay)); + const delay = Math.pow(2, i) * 1000 // 指数退避 + logger.warn(`⏳ Retry ${i + 1}/${maxRetries} in ${delay}ms: ${error.message}`) + await new Promise((resolve) => setTimeout(resolve, delay)) } } } - - throw lastError; + + throw lastError } // 🎯 健康检查 async healthCheck() { try { - const accounts = await claudeAccountService.getAllAccounts(); - const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active'); - + const accounts = await claudeAccountService.getAllAccounts() + const activeAccounts = accounts.filter((acc) => acc.isActive && acc.status === 'active') + return { healthy: activeAccounts.length > 0, activeAccounts: activeAccounts.length, totalAccounts: accounts.length, timestamp: new Date().toISOString() - }; + } } catch (error) { - logger.error('❌ Health check failed:', error); + logger.error('❌ Health check failed:', error) return { healthy: false, error: error.message, timestamp: new Date().toISOString() - }; + } } } } -module.exports = new ClaudeRelayService(); \ No newline at end of file +module.exports = new ClaudeRelayService() diff --git a/src/services/costInitService.js b/src/services/costInitService.js index e61063dd..ead54d46 100644 --- a/src/services/costInitService.js +++ b/src/services/costInitService.js @@ -1,7 +1,7 @@ -const redis = require('../models/redis'); -const apiKeyService = require('./apiKeyService'); -const CostCalculator = require('../utils/costCalculator'); -const logger = require('../utils/logger'); +const redis = require('../models/redis') +const apiKeyService = require('./apiKeyService') +const CostCalculator = require('../utils/costCalculator') +const logger = require('../utils/logger') class CostInitService { /** @@ -10,173 +10,187 @@ class CostInitService { */ async initializeAllCosts() { try { - logger.info('💰 Starting cost initialization for all API Keys...'); - - const apiKeys = await apiKeyService.getAllApiKeys(); - const client = redis.getClientSafe(); - - let processedCount = 0; - let errorCount = 0; - + logger.info('💰 Starting cost initialization for all API Keys...') + + const apiKeys = await apiKeyService.getAllApiKeys() + const client = redis.getClientSafe() + + let processedCount = 0 + let errorCount = 0 + for (const apiKey of apiKeys) { try { - await this.initializeApiKeyCosts(apiKey.id, client); - processedCount++; - + await this.initializeApiKeyCosts(apiKey.id, client) + processedCount++ + if (processedCount % 10 === 0) { - logger.info(`💰 Processed ${processedCount} API Keys...`); + logger.info(`💰 Processed ${processedCount} API Keys...`) } } catch (error) { - errorCount++; - logger.error(`❌ Failed to initialize costs for API Key ${apiKey.id}:`, error); + errorCount++ + logger.error(`❌ Failed to initialize costs for API Key ${apiKey.id}:`, error) } } - - logger.success(`💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}`); - return { processed: processedCount, errors: errorCount }; + + logger.success( + `💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}` + ) + return { processed: processedCount, errors: errorCount } } catch (error) { - logger.error('❌ Failed to initialize costs:', error); - throw error; + logger.error('❌ Failed to initialize costs:', error) + throw error } } - + /** * 初始化单个API Key的费用数据 */ async initializeApiKeyCosts(apiKeyId, client) { // 获取所有时间的模型使用统计 - const modelKeys = await client.keys(`usage:${apiKeyId}:model:*:*:*`); - + const modelKeys = await client.keys(`usage:${apiKeyId}:model:*:*:*`) + // 按日期分组统计 - const dailyCosts = new Map(); // date -> cost - const monthlyCosts = new Map(); // month -> cost - const hourlyCosts = new Map(); // hour -> cost - + const dailyCosts = new Map() // date -> cost + const monthlyCosts = new Map() // month -> cost + const hourlyCosts = new Map() // hour -> cost + for (const key of modelKeys) { // 解析key格式: usage:{keyId}:model:{period}:{model}:{date} - const match = key.match(/usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/); - if (!match) continue; - - const [, , period, model, dateStr] = match; - + const match = key.match( + /usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/ + ) + if (!match) { + continue + } + + const [, , period, model, dateStr] = match + // 获取使用数据 - const data = await client.hgetall(key); - if (!data || Object.keys(data).length === 0) continue; - + const data = await client.hgetall(key) + if (!data || Object.keys(data).length === 0) { + continue + } + // 计算费用 const usage = { input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0, output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0, - cache_creation_input_tokens: parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0, - cache_read_input_tokens: parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 - }; - - const costResult = CostCalculator.calculateCost(usage, model); - const cost = costResult.costs.total; - + cache_creation_input_tokens: + parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0, + cache_read_input_tokens: + parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + } + + const costResult = CostCalculator.calculateCost(usage, model) + const cost = costResult.costs.total + // 根据period分组累加费用 if (period === 'daily') { - const currentCost = dailyCosts.get(dateStr) || 0; - dailyCosts.set(dateStr, currentCost + cost); + const currentCost = dailyCosts.get(dateStr) || 0 + dailyCosts.set(dateStr, currentCost + cost) } else if (period === 'monthly') { - const currentCost = monthlyCosts.get(dateStr) || 0; - monthlyCosts.set(dateStr, currentCost + cost); + const currentCost = monthlyCosts.get(dateStr) || 0 + monthlyCosts.set(dateStr, currentCost + cost) } else if (period === 'hourly') { - const currentCost = hourlyCosts.get(dateStr) || 0; - hourlyCosts.set(dateStr, currentCost + cost); + const currentCost = hourlyCosts.get(dateStr) || 0 + hourlyCosts.set(dateStr, currentCost + cost) } } - + // 将计算出的费用写入Redis - const promises = []; - + const promises = [] + // 写入每日费用 for (const [date, cost] of dailyCosts) { - const key = `usage:cost:daily:${apiKeyId}:${date}`; + const key = `usage:cost:daily:${apiKeyId}:${date}` promises.push( client.set(key, cost.toString()), client.expire(key, 86400 * 30) // 30天过期 - ); + ) } - + // 写入每月费用 for (const [month, cost] of monthlyCosts) { - const key = `usage:cost:monthly:${apiKeyId}:${month}`; + const key = `usage:cost:monthly:${apiKeyId}:${month}` promises.push( client.set(key, cost.toString()), client.expire(key, 86400 * 90) // 90天过期 - ); + ) } - + // 写入每小时费用 for (const [hour, cost] of hourlyCosts) { - const key = `usage:cost:hourly:${apiKeyId}:${hour}`; + const key = `usage:cost:hourly:${apiKeyId}:${hour}` promises.push( client.set(key, cost.toString()), client.expire(key, 86400 * 7) // 7天过期 - ); + ) } - + // 计算总费用 - let totalCost = 0; + let totalCost = 0 for (const cost of dailyCosts.values()) { - totalCost += cost; + totalCost += cost } - + // 写入总费用 if (totalCost > 0) { - const totalKey = `usage:cost:total:${apiKeyId}`; - promises.push(client.set(totalKey, totalCost.toString())); + const totalKey = `usage:cost:total:${apiKeyId}` + promises.push(client.set(totalKey, totalCost.toString())) } - - await Promise.all(promises); - - logger.debug(`💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}`); + + await Promise.all(promises) + + logger.debug( + `💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}` + ) } - + /** * 检查是否需要初始化费用数据 */ async needsInitialization() { try { - const client = redis.getClientSafe(); - + const client = redis.getClientSafe() + // 检查是否有任何费用数据 - const costKeys = await client.keys('usage:cost:*'); - + const costKeys = await client.keys('usage:cost:*') + // 如果没有费用数据,需要初始化 if (costKeys.length === 0) { - logger.info('💰 No cost data found, initialization needed'); - return true; + logger.info('💰 No cost data found, initialization needed') + return true } - + // 检查是否有使用数据但没有对应的费用数据 - const sampleKeys = await client.keys('usage:*:model:daily:*:*'); + const sampleKeys = await client.keys('usage:*:model:daily:*:*') if (sampleKeys.length > 10) { // 抽样检查 - const sampleSize = Math.min(10, sampleKeys.length); + const sampleSize = Math.min(10, sampleKeys.length) for (let i = 0; i < sampleSize; i++) { - const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)]; - const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/); + const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)] + const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) if (match) { - const [, keyId, , date] = match; - const costKey = `usage:cost:daily:${keyId}:${date}`; - const hasCost = await client.exists(costKey); + const [, keyId, , date] = match + const costKey = `usage:cost:daily:${keyId}:${date}` + const hasCost = await client.exists(costKey) if (!hasCost) { - logger.info(`💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed`); - return true; + logger.info( + `💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed` + ) + return true } } } } - - logger.info('💰 Cost data appears to be up to date'); - return false; + + logger.info('💰 Cost data appears to be up to date') + return false } catch (error) { - logger.error('❌ Failed to check initialization status:', error); - return false; + logger.error('❌ Failed to check initialization status:', error) + return false } } } -module.exports = new CostInitService(); \ No newline at end of file +module.exports = new CostInitService() diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index 086b886e..ad6a30b3 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -1,91 +1,91 @@ -const redisClient = require('../models/redis'); -const { v4: uuidv4 } = require('uuid'); -const crypto = require('crypto'); -const config = require('../../config/config'); -const logger = require('../utils/logger'); -const { OAuth2Client } = require('google-auth-library'); -const { maskToken } = require('../utils/tokenMask'); +const redisClient = require('../models/redis') +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const config = require('../../config/config') +const logger = require('../utils/logger') +const { OAuth2Client } = require('google-auth-library') +const { maskToken } = require('../utils/tokenMask') const { logRefreshStart, logRefreshSuccess, logRefreshError, logTokenUsage, logRefreshSkipped -} = require('../utils/tokenRefreshLogger'); -const tokenRefreshService = require('./tokenRefreshService'); +} = require('../utils/tokenRefreshLogger') +const tokenRefreshService = require('./tokenRefreshService') // Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据 -const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; -const OAUTH_CLIENT_SECRET = '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'; -const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']; +const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com' +const OAUTH_CLIENT_SECRET = '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' +const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform'] // 加密相关常量 -const ALGORITHM = 'aes-256-cbc'; -const ENCRYPTION_SALT = 'gemini-account-salt'; -const IV_LENGTH = 16; +const ALGORITHM = 'aes-256-cbc' +const ENCRYPTION_SALT = 'gemini-account-salt' +const IV_LENGTH = 16 // 生成加密密钥(使用与 claudeAccountService 相同的方法) function generateEncryptionKey() { - return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32); + return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32) } // Gemini 账户键前缀 -const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'; -const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'; -const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:'; +const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:' +const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts' +const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:' // 加密函数 function encrypt(text) { - if (!text) return ''; - const key = generateEncryptionKey(); - const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); - let encrypted = cipher.update(text); - encrypted = Buffer.concat([encrypted, cipher.final()]); - return iv.toString('hex') + ':' + encrypted.toString('hex'); + if (!text) { + return '' + } + const key = generateEncryptionKey() + const iv = crypto.randomBytes(IV_LENGTH) + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + let encrypted = cipher.update(text) + encrypted = Buffer.concat([encrypted, cipher.final()]) + return `${iv.toString('hex')}:${encrypted.toString('hex')}` } // 解密函数 function decrypt(text) { - if (!text) return ''; + if (!text) { + return '' + } try { - const key = generateEncryptionKey(); + const key = generateEncryptionKey() // IV 是固定长度的 32 个十六进制字符(16 字节) - const ivHex = text.substring(0, 32); - const encryptedHex = text.substring(33); // 跳过冒号 + const ivHex = text.substring(0, 32) + const encryptedHex = text.substring(33) // 跳过冒号 - const iv = Buffer.from(ivHex, 'hex'); - const encryptedText = Buffer.from(encryptedHex, 'hex'); - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); - let decrypted = decipher.update(encryptedText); - decrypted = Buffer.concat([decrypted, decipher.final()]); - return decrypted.toString(); + const iv = Buffer.from(ivHex, 'hex') + const encryptedText = Buffer.from(encryptedHex, 'hex') + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + let decrypted = decipher.update(encryptedText) + decrypted = Buffer.concat([decrypted, decipher.final()]) + return decrypted.toString() } catch (error) { - logger.error('Decryption error:', error); - return ''; + logger.error('Decryption error:', error) + return '' } } // 创建 OAuth2 客户端 function createOAuth2Client(redirectUri = null) { // 如果没有提供 redirectUri,使用默认值 - const uri = redirectUri || 'http://localhost:45462'; - return new OAuth2Client( - OAUTH_CLIENT_ID, - OAUTH_CLIENT_SECRET, - uri - ); + const uri = redirectUri || 'http://localhost:45462' + return new OAuth2Client(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, uri) } // 生成授权 URL (支持 PKCE) async function generateAuthUrl(state = null, redirectUri = null) { // 使用新的 redirect URI - const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode'; - const oAuth2Client = createOAuth2Client(finalRedirectUri); + const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode' + const oAuth2Client = createOAuth2Client(finalRedirectUri) // 生成 PKCE code verifier - const codeVerifier = await oAuth2Client.generateCodeVerifierAsync(); - const stateValue = state || crypto.randomBytes(32).toString('hex'); + const codeVerifier = await oAuth2Client.generateCodeVerifierAsync() + const stateValue = state || crypto.randomBytes(32).toString('hex') const authUrl = oAuth2Client.generateAuthUrl({ redirect_uri: finalRedirectUri, @@ -95,84 +95,84 @@ async function generateAuthUrl(state = null, redirectUri = null) { code_challenge: codeVerifier.codeChallenge, state: stateValue, prompt: 'select_account' - }); + }) return { authUrl, state: stateValue, codeVerifier: codeVerifier.codeVerifier, redirectUri: finalRedirectUri - }; + } } // 轮询检查 OAuth 授权状态 async function pollAuthorizationStatus(sessionId, maxAttempts = 60, interval = 2000) { - let attempts = 0; - const client = redisClient.getClientSafe(); + let attempts = 0 + const client = redisClient.getClientSafe() while (attempts < maxAttempts) { try { - const sessionData = await client.get(`oauth_session:${sessionId}`); + const sessionData = await client.get(`oauth_session:${sessionId}`) if (!sessionData) { - throw new Error('OAuth session not found'); + throw new Error('OAuth session not found') } - const session = JSON.parse(sessionData); + const session = JSON.parse(sessionData) if (session.code) { // 授权码已获取,交换 tokens - const tokens = await exchangeCodeForTokens(session.code); + const tokens = await exchangeCodeForTokens(session.code) // 清理 session - await client.del(`oauth_session:${sessionId}`); + await client.del(`oauth_session:${sessionId}`) return { success: true, tokens - }; + } } if (session.error) { // 授权失败 - await client.del(`oauth_session:${sessionId}`); + await client.del(`oauth_session:${sessionId}`) return { success: false, error: session.error - }; + } } // 等待下一次轮询 - await new Promise(resolve => setTimeout(resolve, interval)); - attempts++; + await new Promise((resolve) => setTimeout(resolve, interval)) + attempts++ } catch (error) { - logger.error('Error polling authorization status:', error); - throw error; + logger.error('Error polling authorization status:', error) + throw error } } // 超时 - await client.del(`oauth_session:${sessionId}`); + await client.del(`oauth_session:${sessionId}`) return { success: false, error: 'Authorization timeout' - }; + } } // 交换授权码获取 tokens (支持 PKCE) async function exchangeCodeForTokens(code, redirectUri = null, codeVerifier = null) { - const oAuth2Client = createOAuth2Client(redirectUri); + const oAuth2Client = createOAuth2Client(redirectUri) try { const tokenParams = { - code: code, + code, redirect_uri: redirectUri - }; + } // 如果提供了 codeVerifier,添加到参数中 if (codeVerifier) { - tokenParams.codeVerifier = codeVerifier; + tokenParams.codeVerifier = codeVerifier } - const { tokens } = await oAuth2Client.getToken(tokenParams); + const { tokens } = await oAuth2Client.getToken(tokenParams) // 转换为兼容格式 return { @@ -180,34 +180,36 @@ async function exchangeCodeForTokens(code, redirectUri = null, codeVerifier = nu refresh_token: tokens.refresh_token, scope: tokens.scope || OAUTH_SCOPES.join(' '), token_type: tokens.token_type || 'Bearer', - expiry_date: tokens.expiry_date || Date.now() + (tokens.expires_in * 1000) - }; + expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000 + } } catch (error) { - logger.error('Error exchanging code for tokens:', error); - throw new Error('Failed to exchange authorization code'); + logger.error('Error exchanging code for tokens:', error) + throw new Error('Failed to exchange authorization code') } } // 刷新访问令牌 async function refreshAccessToken(refreshToken) { - const oAuth2Client = createOAuth2Client(); + const oAuth2Client = createOAuth2Client() try { // 设置 refresh_token oAuth2Client.setCredentials({ refresh_token: refreshToken - }); + }) // 调用 refreshAccessToken 获取新的 tokens - const response = await oAuth2Client.refreshAccessToken(); - const credentials = response.credentials; + const response = await oAuth2Client.refreshAccessToken() + const { credentials } = response // 检查是否成功获取了新的 access_token if (!credentials || !credentials.access_token) { - throw new Error('No access token returned from refresh'); + throw new Error('No access token returned from refresh') } - logger.info(`🔄 Successfully refreshed Gemini token. New expiry: ${new Date(credentials.expiry_date).toISOString()}`); + logger.info( + `🔄 Successfully refreshed Gemini token. New expiry: ${new Date(credentials.expiry_date).toISOString()}` + ) return { access_token: credentials.access_token, @@ -215,48 +217,48 @@ async function refreshAccessToken(refreshToken) { scope: credentials.scope || OAUTH_SCOPES.join(' '), token_type: credentials.token_type || 'Bearer', expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期 - }; + } } catch (error) { logger.error('Error refreshing access token:', { message: error.message, code: error.code, response: error.response?.data - }); - throw new Error(`Failed to refresh access token: ${error.message}`); + }) + throw new Error(`Failed to refresh access token: ${error.message}`) } } // 创建 Gemini 账户 async function createAccount(accountData) { - const id = uuidv4(); - const now = new Date().toISOString(); + const id = uuidv4() + const now = new Date().toISOString() // 处理凭证数据 - let geminiOauth = null; - let accessToken = ''; - let refreshToken = ''; - let expiresAt = ''; + let geminiOauth = null + let accessToken = '' + let refreshToken = '' + let expiresAt = '' if (accountData.geminiOauth || accountData.accessToken) { // 如果提供了完整的 OAuth 数据 if (accountData.geminiOauth) { - geminiOauth = typeof accountData.geminiOauth === 'string' - ? accountData.geminiOauth - : JSON.stringify(accountData.geminiOauth); + geminiOauth = + typeof accountData.geminiOauth === 'string' + ? accountData.geminiOauth + : JSON.stringify(accountData.geminiOauth) - const oauthData = typeof accountData.geminiOauth === 'string' - ? JSON.parse(accountData.geminiOauth) - : accountData.geminiOauth; + const oauthData = + typeof accountData.geminiOauth === 'string' + ? JSON.parse(accountData.geminiOauth) + : accountData.geminiOauth - accessToken = oauthData.access_token || ''; - refreshToken = oauthData.refresh_token || ''; - expiresAt = oauthData.expiry_date - ? new Date(oauthData.expiry_date).toISOString() - : ''; + accessToken = oauthData.access_token || '' + refreshToken = oauthData.refresh_token || '' + expiresAt = oauthData.expiry_date ? new Date(oauthData.expiry_date).toISOString() : '' } else { // 如果只提供了 access token - accessToken = accountData.accessToken; - refreshToken = accountData.refreshToken || ''; + ;({ accessToken } = accountData) + refreshToken = accountData.refreshToken || '' // 构造完整的 OAuth 数据 geminiOauth = JSON.stringify({ @@ -265,9 +267,9 @@ async function createAccount(accountData) { scope: accountData.scope || OAUTH_SCOPES.join(' '), token_type: accountData.tokenType || 'Bearer', expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时 - }); + }) - expiresAt = new Date(accountData.expiryDate || Date.now() + 3600000).toISOString(); + expiresAt = new Date(accountData.expiryDate || Date.now() + 3600000).toISOString() } } @@ -279,7 +281,7 @@ async function createAccount(accountData) { accountType: accountData.accountType || 'shared', isActive: 'true', status: 'active', - + // 调度相关 schedulable: accountData.schedulable !== undefined ? String(accountData.schedulable) : 'true', priority: accountData.priority || 50, // 调度优先级 (1-100,数字越小优先级越高) @@ -296,7 +298,7 @@ async function createAccount(accountData) { // 项目 ID(Google Cloud/Workspace 账号需要) projectId: accountData.projectId || '', - + // 支持的模型列表(可选) supportedModels: accountData.supportedModels || [], // 空数组表示支持所有模型 @@ -305,86 +307,83 @@ async function createAccount(accountData) { updatedAt: now, lastUsedAt: '', lastRefreshAt: '' - }; + } // 保存到 Redis - const client = redisClient.getClientSafe(); - await client.hset( - `${GEMINI_ACCOUNT_KEY_PREFIX}${id}`, - account - ); + const client = redisClient.getClientSafe() + await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${id}`, account) // 如果是共享账户,添加到共享账户集合 if (account.accountType === 'shared') { - await client.sadd(SHARED_GEMINI_ACCOUNTS_KEY, id); + await client.sadd(SHARED_GEMINI_ACCOUNTS_KEY, id) } - logger.info(`Created Gemini account: ${id}`); - + logger.info(`Created Gemini account: ${id}`) + // 返回时解析代理配置 - const returnAccount = { ...account }; + const returnAccount = { ...account } if (returnAccount.proxy) { try { - returnAccount.proxy = JSON.parse(returnAccount.proxy); + returnAccount.proxy = JSON.parse(returnAccount.proxy) } catch (e) { - returnAccount.proxy = null; + returnAccount.proxy = null } } - - return returnAccount; + + return returnAccount } // 获取账户 async function getAccount(accountId) { - const client = redisClient.getClientSafe(); - const accountData = await client.hgetall(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`); + const client = redisClient.getClientSafe() + const accountData = await client.hgetall(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`) if (!accountData || Object.keys(accountData).length === 0) { - return null; + return null } // 解密敏感字段 if (accountData.geminiOauth) { - accountData.geminiOauth = decrypt(accountData.geminiOauth); + accountData.geminiOauth = decrypt(accountData.geminiOauth) } if (accountData.accessToken) { - accountData.accessToken = decrypt(accountData.accessToken); + accountData.accessToken = decrypt(accountData.accessToken) } if (accountData.refreshToken) { - accountData.refreshToken = decrypt(accountData.refreshToken); + accountData.refreshToken = decrypt(accountData.refreshToken) } // 解析代理配置 if (accountData.proxy) { try { - accountData.proxy = JSON.parse(accountData.proxy); + accountData.proxy = JSON.parse(accountData.proxy) } catch (e) { // 如果解析失败,保持原样或设置为null - accountData.proxy = null; + accountData.proxy = null } } - return accountData; + return accountData } // 更新账户 async function updateAccount(accountId, updates) { - const existingAccount = await getAccount(accountId); + const existingAccount = await getAccount(accountId) if (!existingAccount) { - throw new Error('Account not found'); + throw new Error('Account not found') } - const now = new Date().toISOString(); - updates.updatedAt = now; + const now = new Date().toISOString() + updates.updatedAt = now // 检查是否新增了 refresh token // existingAccount.refreshToken 已经是解密后的值了(从 getAccount 返回) - const oldRefreshToken = existingAccount.refreshToken || ''; - let needUpdateExpiry = false; + const oldRefreshToken = existingAccount.refreshToken || '' + let needUpdateExpiry = false // 处理代理设置 if (updates.proxy !== undefined) { - updates.proxy = updates.proxy ? JSON.stringify(updates.proxy) : ''; + updates.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' } // 加密敏感字段 @@ -393,173 +392,173 @@ async function updateAccount(accountId, updates) { typeof updates.geminiOauth === 'string' ? updates.geminiOauth : JSON.stringify(updates.geminiOauth) - ); + ) } if (updates.accessToken) { - updates.accessToken = encrypt(updates.accessToken); + updates.accessToken = encrypt(updates.accessToken) } if (updates.refreshToken) { - updates.refreshToken = encrypt(updates.refreshToken); + updates.refreshToken = encrypt(updates.refreshToken) // 如果之前没有 refresh token,现在有了,标记需要更新过期时间 if (!oldRefreshToken && updates.refreshToken) { - needUpdateExpiry = true; + needUpdateExpiry = true } } // 更新账户类型时处理共享账户集合 - const client = redisClient.getClientSafe(); + const client = redisClient.getClientSafe() if (updates.accountType && updates.accountType !== existingAccount.accountType) { if (updates.accountType === 'shared') { - await client.sadd(SHARED_GEMINI_ACCOUNTS_KEY, accountId); + await client.sadd(SHARED_GEMINI_ACCOUNTS_KEY, accountId) } else { - await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId); + await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId) } } // 如果新增了 refresh token,更新过期时间为10分钟 if (needUpdateExpiry) { - const newExpiry = new Date(Date.now() + (10 * 60 * 1000)).toISOString(); - updates.expiresAt = newExpiry; - logger.info(`🔄 New refresh token added for Gemini account ${accountId}, setting expiry to 10 minutes`); + const newExpiry = new Date(Date.now() + 10 * 60 * 1000).toISOString() + updates.expiresAt = newExpiry + logger.info( + `🔄 New refresh token added for Gemini account ${accountId}, setting expiry to 10 minutes` + ) } // 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token if (updates.geminiOauth && !oldRefreshToken) { - const oauthData = typeof updates.geminiOauth === 'string' - ? JSON.parse(decrypt(updates.geminiOauth)) - : updates.geminiOauth; + const oauthData = + typeof updates.geminiOauth === 'string' + ? JSON.parse(decrypt(updates.geminiOauth)) + : updates.geminiOauth if (oauthData.refresh_token) { // 如果 expiry_date 设置的时间过长(超过1小时),调整为10分钟 - const providedExpiry = oauthData.expiry_date || 0; - const now = Date.now(); - const oneHour = 60 * 60 * 1000; + const providedExpiry = oauthData.expiry_date || 0 + const currentTime = Date.now() + const oneHour = 60 * 60 * 1000 - if (providedExpiry - now > oneHour) { - const newExpiry = new Date(now + (10 * 60 * 1000)).toISOString(); - updates.expiresAt = newExpiry; - logger.info(`🔄 Adjusted expiry time to 10 minutes for Gemini account ${accountId} with refresh token`); + if (providedExpiry - currentTime > oneHour) { + const newExpiry = new Date(currentTime + 10 * 60 * 1000).toISOString() + updates.expiresAt = newExpiry + logger.info( + `🔄 Adjusted expiry time to 10 minutes for Gemini account ${accountId} with refresh token` + ) } } } - await client.hset( - `${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`, - updates - ); + await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + logger.info(`Updated Gemini account: ${accountId}`) - logger.info(`Updated Gemini account: ${accountId}`); - // 合并更新后的账户数据 - const updatedAccount = { ...existingAccount, ...updates }; - + const updatedAccount = { ...existingAccount, ...updates } + // 返回时解析代理配置 if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') { try { - updatedAccount.proxy = JSON.parse(updatedAccount.proxy); + updatedAccount.proxy = JSON.parse(updatedAccount.proxy) } catch (e) { - updatedAccount.proxy = null; + updatedAccount.proxy = null } } - - return updatedAccount; + + return updatedAccount } // 删除账户 async function deleteAccount(accountId) { - const account = await getAccount(accountId); + const account = await getAccount(accountId) if (!account) { - throw new Error('Account not found'); + throw new Error('Account not found') } // 从 Redis 删除 - const client = redisClient.getClientSafe(); - await client.del(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`); + const client = redisClient.getClientSafe() + await client.del(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`) // 从共享账户集合中移除 if (account.accountType === 'shared') { - await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId); + await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId) } // 清理会话映射 - const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`); + const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`) for (const key of sessionMappings) { - const mappedAccountId = await client.get(key); + const mappedAccountId = await client.get(key) if (mappedAccountId === accountId) { - await client.del(key); + await client.del(key) } } - logger.info(`Deleted Gemini account: ${accountId}`); - return true; + logger.info(`Deleted Gemini account: ${accountId}`) + return true } // 获取所有账户 async function getAllAccounts() { - const client = redisClient.getClientSafe(); - const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`); - const accounts = []; + const client = redisClient.getClientSafe() + const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`) + const accounts = [] for (const key of keys) { - const accountData = await client.hgetall(key); + const accountData = await client.hgetall(key) if (accountData && Object.keys(accountData).length > 0) { // 解析代理配置 if (accountData.proxy) { try { - accountData.proxy = JSON.parse(accountData.proxy); + accountData.proxy = JSON.parse(accountData.proxy) } catch (e) { // 如果解析失败,设置为null - accountData.proxy = null; + accountData.proxy = null } } - + // 不解密敏感字段,只返回基本信息 accounts.push({ ...accountData, geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '', accessToken: accountData.accessToken ? '[ENCRYPTED]' : '', refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '' - }); + }) } } - return accounts; + return accounts } // 选择可用账户(支持专属和共享账户) async function selectAvailableAccount(apiKeyId, sessionHash = null) { // 首先检查是否有粘性会话 - const client = redisClient.getClientSafe(); + const client = redisClient.getClientSafe() if (sessionHash) { - const mappedAccountId = await client.get( - `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}` - ); + const mappedAccountId = await client.get(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`) if (mappedAccountId) { - const account = await getAccount(mappedAccountId); + const account = await getAccount(mappedAccountId) if (account && account.isActive === 'true' && !isTokenExpired(account)) { - logger.debug(`Using sticky session account: ${mappedAccountId}`); - return account; + logger.debug(`Using sticky session account: ${mappedAccountId}`) + return account } } } // 获取 API Key 信息 - const apiKeyData = await client.hgetall(`api_key:${apiKeyId}`); + const apiKeyData = await client.hgetall(`api_key:${apiKeyId}`) // 检查是否绑定了 Gemini 账户 if (apiKeyData.geminiAccountId) { - const account = await getAccount(apiKeyData.geminiAccountId); + const account = await getAccount(apiKeyData.geminiAccountId) if (account && account.isActive === 'true') { // 检查 token 是否过期 - const isExpired = isTokenExpired(account); + const isExpired = isTokenExpired(account) // 记录token使用情况 - logTokenUsage(account.id, account.name, 'gemini', account.expiresAt, isExpired); + logTokenUsage(account.id, account.name, 'gemini', account.expiresAt, isExpired) if (isExpired) { - await refreshAccountToken(account.id); - return await getAccount(account.id); + await refreshAccountToken(account.id) + return await getAccount(account.id) } // 创建粘性会话映射 @@ -568,131 +567,137 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) { `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, // 1小时过期 account.id - ); + ) } - return account; + return account } } // 从共享账户池选择 - const sharedAccountIds = await client.smembers(SHARED_GEMINI_ACCOUNTS_KEY); - const availableAccounts = []; + const sharedAccountIds = await client.smembers(SHARED_GEMINI_ACCOUNTS_KEY) + const availableAccounts = [] for (const accountId of sharedAccountIds) { - const account = await getAccount(accountId); + const account = await getAccount(accountId) if (account && account.isActive === 'true' && !isRateLimited(account)) { - availableAccounts.push(account); + availableAccounts.push(account) } } if (availableAccounts.length === 0) { - throw new Error('No available Gemini accounts'); + throw new Error('No available Gemini accounts') } // 选择最少使用的账户 availableAccounts.sort((a, b) => { - const aLastUsed = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0; - const bLastUsed = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0; - return aLastUsed - bLastUsed; - }); + const aLastUsed = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0 + const bLastUsed = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0 + return aLastUsed - bLastUsed + }) - const selectedAccount = availableAccounts[0]; + const selectedAccount = availableAccounts[0] // 检查并刷新 token - const isExpired = isTokenExpired(selectedAccount); + const isExpired = isTokenExpired(selectedAccount) // 记录token使用情况 - logTokenUsage(selectedAccount.id, selectedAccount.name, 'gemini', selectedAccount.expiresAt, isExpired); + logTokenUsage( + selectedAccount.id, + selectedAccount.name, + 'gemini', + selectedAccount.expiresAt, + isExpired + ) if (isExpired) { - await refreshAccountToken(selectedAccount.id); - return await getAccount(selectedAccount.id); + await refreshAccountToken(selectedAccount.id) + return await getAccount(selectedAccount.id) } // 创建粘性会话映射 if (sessionHash) { - await client.setex( - `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, - 3600, - selectedAccount.id - ); + await client.setex(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, selectedAccount.id) } - return selectedAccount; + return selectedAccount } // 检查 token 是否过期 function isTokenExpired(account) { - if (!account.expiresAt) return true; + if (!account.expiresAt) { + return true + } - const expiryTime = new Date(account.expiresAt).getTime(); - const now = Date.now(); - const buffer = 10 * 1000; // 10秒缓冲 + const expiryTime = new Date(account.expiresAt).getTime() + const now = Date.now() + const buffer = 10 * 1000 // 10秒缓冲 - return now >= (expiryTime - buffer); + return now >= expiryTime - buffer } // 检查账户是否被限流 function isRateLimited(account) { if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { - const limitedAt = new Date(account.rateLimitedAt).getTime(); - const now = Date.now(); - const limitDuration = 60 * 60 * 1000; // 1小时 + const limitedAt = new Date(account.rateLimitedAt).getTime() + const now = Date.now() + const limitDuration = 60 * 60 * 1000 // 1小时 - return now < (limitedAt + limitDuration); + return now < limitedAt + limitDuration } - return false; + return false } // 刷新账户 token async function refreshAccountToken(accountId) { - let lockAcquired = false; - let account = null; + let lockAcquired = false + let account = null try { - account = await getAccount(accountId); + account = await getAccount(accountId) if (!account) { - throw new Error('Account not found'); + throw new Error('Account not found') } if (!account.refreshToken) { - throw new Error('No refresh token available'); + throw new Error('No refresh token available') } // 尝试获取分布式锁 - lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'gemini'); + lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'gemini') if (!lockAcquired) { // 如果无法获取锁,说明另一个进程正在刷新 - logger.info(`🔒 Token refresh already in progress for Gemini account: ${account.name} (${accountId})`); - logRefreshSkipped(accountId, account.name, 'gemini', 'already_locked'); + logger.info( + `🔒 Token refresh already in progress for Gemini account: ${account.name} (${accountId})` + ) + logRefreshSkipped(accountId, account.name, 'gemini', 'already_locked') // 等待一段时间后返回,期望其他进程已完成刷新 - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 2000)) // 重新获取账户数据(可能已被其他进程刷新) - const updatedAccount = await getAccount(accountId); + const updatedAccount = await getAccount(accountId) if (updatedAccount && updatedAccount.accessToken) { - const accessToken = decrypt(updatedAccount.accessToken); + const accessToken = decrypt(updatedAccount.accessToken) return { access_token: accessToken, refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '', expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0, scope: updatedAccount.scope || OAUTH_SCOPES.join(' '), token_type: 'Bearer' - }; + } } - throw new Error('Token refresh in progress by another process'); + throw new Error('Token refresh in progress by another process') } // 记录开始刷新 - logRefreshStart(accountId, account.name, 'gemini', 'manual_refresh'); - logger.info(`🔄 Starting token refresh for Gemini account: ${account.name} (${accountId})`); + logRefreshStart(accountId, account.name, 'gemini', 'manual_refresh') + logger.info(`🔄 Starting token refresh for Gemini account: ${account.name} (${accountId})`) // account.refreshToken 已经是解密后的值(从 getAccount 返回) - const newTokens = await refreshAccessToken(account.refreshToken); + const newTokens = await refreshAccessToken(account.refreshToken) // 更新账户信息 const updates = { @@ -701,11 +706,11 @@ async function refreshAccountToken(accountId) { expiresAt: new Date(newTokens.expiry_date).toISOString(), lastRefreshAt: new Date().toISOString(), geminiOauth: JSON.stringify(newTokens), - status: 'active', // 刷新成功后,将状态更新为 active - errorMessage: '' // 清空错误信息 - }; + status: 'active', // 刷新成功后,将状态更新为 active + errorMessage: '' // 清空错误信息 + } - await updateAccount(accountId, updates); + await updateAccount(accountId, updates) // 记录刷新成功 logRefreshSuccess(accountId, account.name, 'gemini', { @@ -713,16 +718,18 @@ async function refreshAccountToken(accountId) { refreshToken: newTokens.refresh_token, expiresAt: newTokens.expiry_date, scopes: newTokens.scope - }); + }) - logger.info(`Refreshed token for Gemini account: ${accountId} - Access Token: ${maskToken(newTokens.access_token)}`); + logger.info( + `Refreshed token for Gemini account: ${accountId} - Access Token: ${maskToken(newTokens.access_token)}` + ) - return newTokens; + return newTokens } catch (error) { // 记录刷新失败 - logRefreshError(accountId, account ? account.name : 'Unknown', 'gemini', error); + logRefreshError(accountId, account ? account.name : 'Unknown', 'gemini', error) - logger.error(`Failed to refresh token for account ${accountId}:`, error); + logger.error(`Failed to refresh token for account ${accountId}:`, error) // 标记账户为错误状态(只有在账户存在时) if (account) { @@ -730,17 +737,17 @@ async function refreshAccountToken(accountId) { await updateAccount(accountId, { status: 'error', errorMessage: error.message - }); + }) } catch (updateError) { - logger.error('Failed to update account status after refresh error:', updateError); + logger.error('Failed to update account status after refresh error:', updateError) } } - throw error; + throw error } finally { // 释放锁 if (lockAcquired) { - await tokenRefreshService.releaseRefreshLock(accountId, 'gemini'); + await tokenRefreshService.releaseRefreshLock(accountId, 'gemini') } } } @@ -749,86 +756,89 @@ async function refreshAccountToken(accountId) { async function markAccountUsed(accountId) { await updateAccount(accountId, { lastUsedAt: new Date().toISOString() - }); + }) } // 设置账户限流状态 async function setAccountRateLimited(accountId, isLimited = true) { - const updates = isLimited ? { - rateLimitStatus: 'limited', - rateLimitedAt: new Date().toISOString() - } : { - rateLimitStatus: '', - rateLimitedAt: '' - }; + const updates = isLimited + ? { + rateLimitStatus: 'limited', + rateLimitedAt: new Date().toISOString() + } + : { + rateLimitStatus: '', + rateLimitedAt: '' + } - await updateAccount(accountId, updates); + await updateAccount(accountId, updates) } // 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法 async function getOauthClient(accessToken, refreshToken) { const client = new OAuth2Client({ clientId: OAUTH_CLIENT_ID, - clientSecret: OAUTH_CLIENT_SECRET, - }); + clientSecret: OAUTH_CLIENT_SECRET + }) const creds = { - 'access_token': accessToken, - 'refresh_token': refreshToken, - 'scope': 'https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email', - 'token_type': 'Bearer', - 'expiry_date': 1754269905646 - }; + access_token: accessToken, + refresh_token: refreshToken, + scope: + 'https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email', + token_type: 'Bearer', + expiry_date: 1754269905646 + } // 设置凭据 - client.setCredentials(creds); + client.setCredentials(creds) // 验证凭据本地有效性 - const { token } = await client.getAccessToken(); + const { token } = await client.getAccessToken() if (!token) { - return false; + return false } // 验证服务器端token状态(检查是否被撤销) - await client.getTokenInfo(token); + await client.getTokenInfo(token) - logger.info('✅ OAuth客户端已创建'); - return client; + logger.info('✅ OAuth客户端已创建') + return client } // 调用 Google Code Assist API 的 loadCodeAssist 方法 async function loadCodeAssist(client, projectId = null) { - const axios = require('axios'); - const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'; - const CODE_ASSIST_API_VERSION = 'v1internal'; + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' - const { token } = await client.getAccessToken(); + const { token } = await client.getAccessToken() // 创建ClientMetadata const clientMetadata = { ideType: 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI', - duetProject: projectId, - }; + duetProject: projectId + } const request = { cloudaicompanionProject: projectId, - metadata: clientMetadata, - }; + metadata: clientMetadata + } const response = await axios({ url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:loadCodeAssist`, method: 'POST', headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' }, data: request, - timeout: 30000, - }); + timeout: 30000 + }) - logger.info('📋 loadCodeAssist API调用成功'); - return response.data; + logger.info('📋 loadCodeAssist API调用成功') + return response.data } // 获取onboard层级 - 参考GeminiCliSimulator的getOnboardTier方法 @@ -838,15 +848,15 @@ function getOnboardTier(loadRes) { LEGACY: 'LEGACY', FREE: 'FREE', PRO: 'PRO' - }; + } if (loadRes.currentTier) { - return loadRes.currentTier; + return loadRes.currentTier } for (const tier of loadRes.allowedTiers || []) { if (tier.isDefault) { - return tier; + return tier } } @@ -854,73 +864,73 @@ function getOnboardTier(loadRes) { name: '', description: '', id: UserTierId.LEGACY, - userDefinedCloudaicompanionProject: true, - }; + userDefinedCloudaicompanionProject: true + } } // 调用 Google Code Assist API 的 onboardUser 方法(包含轮询逻辑) async function onboardUser(client, tierId, projectId, clientMetadata) { - const axios = require('axios'); - const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'; - const CODE_ASSIST_API_VERSION = 'v1internal'; + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' - const { token } = await client.getAccessToken(); + const { token } = await client.getAccessToken() const onboardReq = { - tierId: tierId, + tierId, cloudaicompanionProject: projectId, - metadata: clientMetadata, - }; + metadata: clientMetadata + } - logger.info('📋 开始onboardUser API调用', { tierId, projectId }); + logger.info('📋 开始onboardUser API调用', { tierId, projectId }) // 轮询onboardUser直到长运行操作完成 let lroRes = await axios({ url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`, method: 'POST', headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' }, data: onboardReq, - timeout: 30000, - }); + timeout: 30000 + }) - let attempts = 0; - const maxAttempts = 12; // 最多等待1分钟(5秒 * 12次) + let attempts = 0 + const maxAttempts = 12 // 最多等待1分钟(5秒 * 12次) while (!lroRes.data.done && attempts < maxAttempts) { - logger.info(`⏳ 等待onboardUser完成... (${attempts + 1}/${maxAttempts})`); - await new Promise(resolve => setTimeout(resolve, 5000)); + logger.info(`⏳ 等待onboardUser完成... (${attempts + 1}/${maxAttempts})`) + await new Promise((resolve) => setTimeout(resolve, 5000)) lroRes = await axios({ url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`, method: 'POST', headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' }, data: onboardReq, - timeout: 30000, - }); + timeout: 30000 + }) - attempts++; + attempts++ } if (!lroRes.data.done) { - throw new Error('onboardUser操作超时'); + throw new Error('onboardUser操作超时') } - logger.info('✅ onboardUser API调用完成'); - return lroRes.data; + logger.info('✅ onboardUser API调用完成') + return lroRes.data } // 完整的用户设置流程 - 参考setup.ts的逻辑 async function setupUser(client, initialProjectId = null, clientMetadata = null) { - logger.info('🚀 setupUser 开始', { initialProjectId, hasClientMetadata: !!clientMetadata }); + logger.info('🚀 setupUser 开始', { initialProjectId, hasClientMetadata: !!clientMetadata }) - let projectId = initialProjectId || process.env.GOOGLE_CLOUD_PROJECT || null; - logger.info('📋 初始项目ID', { projectId, fromEnv: !!process.env.GOOGLE_CLOUD_PROJECT }); + let projectId = initialProjectId || process.env.GOOGLE_CLOUD_PROJECT || null + logger.info('📋 初始项目ID', { projectId, fromEnv: !!process.env.GOOGLE_CLOUD_PROJECT }) // 默认的ClientMetadata if (!clientMetadata) { @@ -928,85 +938,96 @@ async function setupUser(client, initialProjectId = null, clientMetadata = null) ideType: 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI', - duetProject: projectId, - }; - logger.info('🔧 使用默认 ClientMetadata'); + duetProject: projectId + } + logger.info('🔧 使用默认 ClientMetadata') } // 调用loadCodeAssist - logger.info('📞 调用 loadCodeAssist...'); - const loadRes = await loadCodeAssist(client, projectId); - logger.info('✅ loadCodeAssist 完成', { hasCloudaicompanionProject: !!loadRes.cloudaicompanionProject }); + logger.info('📞 调用 loadCodeAssist...') + const loadRes = await loadCodeAssist(client, projectId) + logger.info('✅ loadCodeAssist 完成', { + hasCloudaicompanionProject: !!loadRes.cloudaicompanionProject + }) // 如果没有projectId,尝试从loadRes获取 if (!projectId && loadRes.cloudaicompanionProject) { - projectId = loadRes.cloudaicompanionProject; - logger.info('📋 从 loadCodeAssist 获取项目ID', { projectId }); + projectId = loadRes.cloudaicompanionProject + logger.info('📋 从 loadCodeAssist 获取项目ID', { projectId }) } - const tier = getOnboardTier(loadRes); - logger.info('🎯 获取用户层级', { tierId: tier.id, userDefinedProject: tier.userDefinedCloudaicompanionProject }); + const tier = getOnboardTier(loadRes) + logger.info('🎯 获取用户层级', { + tierId: tier.id, + userDefinedProject: tier.userDefinedCloudaicompanionProject + }) if (tier.userDefinedCloudaiCompanionProject && !projectId) { - throw new Error('此账号需要设置GOOGLE_CLOUD_PROJECT环境变量或提供projectId'); + throw new Error('此账号需要设置GOOGLE_CLOUD_PROJECT环境变量或提供projectId') } // 调用onboardUser - logger.info('📞 调用 onboardUser...', { tierId: tier.id, projectId }); - const lroRes = await onboardUser(client, tier.id, projectId, clientMetadata); - logger.info('✅ onboardUser 完成', { hasDone: !!lroRes.done, hasResponse: !!lroRes.response }); + logger.info('📞 调用 onboardUser...', { tierId: tier.id, projectId }) + const lroRes = await onboardUser(client, tier.id, projectId, clientMetadata) + logger.info('✅ onboardUser 完成', { hasDone: !!lroRes.done, hasResponse: !!lroRes.response }) const result = { projectId: lroRes.response?.cloudaicompanionProject?.id || projectId || '', userTier: tier.id, loadRes, onboardRes: lroRes.response || {} - }; + } - logger.info('🎯 setupUser 完成', { resultProjectId: result.projectId, userTier: result.userTier }); - return result; + logger.info('🎯 setupUser 完成', { resultProjectId: result.projectId, userTier: result.userTier }) + return result } // 调用 Code Assist API 计算 token 数量 async function countTokens(client, contents, model = 'gemini-2.0-flash-exp') { - const axios = require('axios'); - const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'; - const CODE_ASSIST_API_VERSION = 'v1internal'; + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' - const { token } = await client.getAccessToken(); + const { token } = await client.getAccessToken() // 按照 gemini-cli 的转换格式构造请求 const request = { request: { model: `models/${model}`, - contents: contents + contents } - }; + } - logger.info('📊 countTokens API调用开始', { model, contentsLength: contents.length }); + logger.info('📊 countTokens API调用开始', { model, contentsLength: contents.length }) const response = await axios({ url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:countTokens`, method: 'POST', headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' }, data: request, - timeout: 30000, - }); + timeout: 30000 + }) - logger.info('✅ countTokens API调用成功', { totalTokens: response.data.totalTokens }); - return response.data; + logger.info('✅ countTokens API调用成功', { totalTokens: response.data.totalTokens }) + return response.data } // 调用 Code Assist API 生成内容(非流式) -async function generateContent(client, requestData, userPromptId, projectId = null, sessionId = null) { - const axios = require('axios'); - const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'; - const CODE_ASSIST_API_VERSION = 'v1internal'; +async function generateContent( + client, + requestData, + userPromptId, + projectId = null, + sessionId = null +) { + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' - const { token } = await client.getAccessToken(); + const { token } = await client.getAccessToken() // 按照 gemini-cli 的转换格式构造请求 const request = { @@ -1017,39 +1038,46 @@ async function generateContent(client, requestData, userPromptId, projectId = nu ...requestData.request, session_id: sessionId } - }; + } - logger.info('🤖 generateContent API调用开始', { - model: requestData.model, + logger.info('🤖 generateContent API调用开始', { + model: requestData.model, userPromptId, projectId, sessionId - }); + }) const axiosConfig = { url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`, method: 'POST', headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' }, data: request, - timeout: 60000, // 生成内容可能需要更长时间 - }; + timeout: 60000 // 生成内容可能需要更长时间 + } - const response = await axios(axiosConfig); + const response = await axios(axiosConfig) - logger.info('✅ generateContent API调用成功'); - return response.data; + logger.info('✅ generateContent API调用成功') + return response.data } // 调用 Code Assist API 生成内容(流式) -async function generateContentStream(client, requestData, userPromptId, projectId = null, sessionId = null, signal = null) { - const axios = require('axios'); - const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'; - const CODE_ASSIST_API_VERSION = 'v1internal'; +async function generateContentStream( + client, + requestData, + userPromptId, + projectId = null, + sessionId = null, + signal = null +) { + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' - const { token } = await client.getAccessToken(); + const { token } = await client.getAccessToken() // 按照 gemini-cli 的转换格式构造请求 const request = { @@ -1060,14 +1088,14 @@ async function generateContentStream(client, requestData, userPromptId, projectI ...requestData.request, session_id: sessionId } - }; + } - logger.info('🌊 streamGenerateContent API调用开始', { - model: requestData.model, + logger.info('🌊 streamGenerateContent API调用开始', { + model: requestData.model, userPromptId, projectId, sessionId - }); + }) const axiosConfig = { url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:streamGenerateContent`, @@ -1076,27 +1104,25 @@ async function generateContentStream(client, requestData, userPromptId, projectI alt: 'sse' }, headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' }, data: request, responseType: 'stream', - timeout: 60000, - }; + timeout: 60000 + } // 如果提供了中止信号,添加到配置中 if (signal) { - axiosConfig.signal = signal; + axiosConfig.signal = signal } - const response = await axios(axiosConfig); + const response = await axios(axiosConfig) - logger.info('✅ streamGenerateContent API调用成功,开始流式传输'); - return response.data; // 返回流对象 + logger.info('✅ streamGenerateContent API调用成功,开始流式传输') + return response.data // 返回流对象 } - - module.exports = { generateAuthUrl, pollAuthorizationStatus, @@ -1122,4 +1148,4 @@ module.exports = { generateContentStream, OAUTH_CLIENT_ID, OAUTH_SCOPES -}; +} diff --git a/src/services/geminiRelayService.js b/src/services/geminiRelayService.js index 5e19c3ea..d636bc92 100644 --- a/src/services/geminiRelayService.js +++ b/src/services/geminiRelayService.js @@ -1,228 +1,243 @@ -const axios = require('axios'); -const { HttpsProxyAgent } = require('https-proxy-agent'); -const { SocksProxyAgent } = require('socks-proxy-agent'); -const logger = require('../utils/logger'); -const config = require('../../config/config'); -const apiKeyService = require('./apiKeyService'); +const axios = require('axios') +const { HttpsProxyAgent } = require('https-proxy-agent') +const { SocksProxyAgent } = require('socks-proxy-agent') +const logger = require('../utils/logger') +const config = require('../../config/config') +const apiKeyService = require('./apiKeyService') // Gemini API 配置 -const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1'; -const DEFAULT_MODEL = 'models/gemini-2.0-flash-exp'; +const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1' +const DEFAULT_MODEL = 'models/gemini-2.0-flash-exp' // 创建代理 agent function createProxyAgent(proxyConfig) { - if (!proxyConfig) return null; - + if (!proxyConfig) { + return null + } + try { - const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig; - + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig + if (!proxy.type || !proxy.host || !proxy.port) { - return null; + return null } - - const proxyUrl = proxy.username && proxy.password - ? `${proxy.type}://${proxy.username}:${proxy.password}@${proxy.host}:${proxy.port}` - : `${proxy.type}://${proxy.host}:${proxy.port}`; - + + const proxyUrl = + proxy.username && proxy.password + ? `${proxy.type}://${proxy.username}:${proxy.password}@${proxy.host}:${proxy.port}` + : `${proxy.type}://${proxy.host}:${proxy.port}` + if (proxy.type === 'socks5') { - return new SocksProxyAgent(proxyUrl); + return new SocksProxyAgent(proxyUrl) } else if (proxy.type === 'http' || proxy.type === 'https') { - return new HttpsProxyAgent(proxyUrl); + return new HttpsProxyAgent(proxyUrl) } } catch (error) { - logger.error('Error creating proxy agent:', error); + logger.error('Error creating proxy agent:', error) } - - return null; + + return null } // 转换 OpenAI 消息格式到 Gemini 格式 function convertMessagesToGemini(messages) { - const contents = []; - let systemInstruction = ''; - + const contents = [] + let systemInstruction = '' + for (const message of messages) { if (message.role === 'system') { - systemInstruction += (systemInstruction ? '\n\n' : '') + message.content; + systemInstruction += (systemInstruction ? '\n\n' : '') + message.content } else if (message.role === 'user') { contents.push({ role: 'user', parts: [{ text: message.content }] - }); + }) } else if (message.role === 'assistant') { contents.push({ role: 'model', parts: [{ text: message.content }] - }); + }) } } - - return { contents, systemInstruction }; + + return { contents, systemInstruction } } // 转换 Gemini 响应到 OpenAI 格式 function convertGeminiResponse(geminiResponse, model, stream = false) { if (stream) { // 流式响应 - const candidate = geminiResponse.candidates?.[0]; - if (!candidate) return null; - - const content = candidate.content?.parts?.[0]?.text || ''; - const finishReason = candidate.finishReason?.toLowerCase(); - + const candidate = geminiResponse.candidates?.[0] + if (!candidate) { + return null + } + + const content = candidate.content?.parts?.[0]?.text || '' + const finishReason = candidate.finishReason?.toLowerCase() + return { id: `chatcmpl-${Date.now()}`, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - delta: { - content: content - }, - finish_reason: finishReason === 'stop' ? 'stop' : null - }] - }; + model, + choices: [ + { + index: 0, + delta: { + content + }, + finish_reason: finishReason === 'stop' ? 'stop' : null + } + ] + } } else { // 非流式响应 - const candidate = geminiResponse.candidates?.[0]; + const candidate = geminiResponse.candidates?.[0] if (!candidate) { - throw new Error('No response from Gemini'); + throw new Error('No response from Gemini') } - - const content = candidate.content?.parts?.[0]?.text || ''; - const finishReason = candidate.finishReason?.toLowerCase() || 'stop'; - + + const content = candidate.content?.parts?.[0]?.text || '' + const finishReason = candidate.finishReason?.toLowerCase() || 'stop' + // 计算 token 使用量 const usage = geminiResponse.usageMetadata || { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 - }; - + } + return { id: `chatcmpl-${Date.now()}`, object: 'chat.completion', created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - message: { - role: 'assistant', - content: content - }, - finish_reason: finishReason - }], + model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content + }, + finish_reason: finishReason + } + ], usage: { prompt_tokens: usage.promptTokenCount, completion_tokens: usage.candidatesTokenCount, total_tokens: usage.totalTokenCount } - }; + } } } // 处理流式响应 async function* handleStreamResponse(response, model, apiKeyId, accountId = null) { - let buffer = ''; + let buffer = '' let totalUsage = { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 - }; - + } + try { for await (const chunk of response.data) { - buffer += chunk.toString(); - + buffer += chunk.toString() + // 处理 SSE 格式的数据 - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // 保留最后一个不完整的行 - + const lines = buffer.split('\n') + buffer = lines.pop() || '' // 保留最后一个不完整的行 + for (const line of lines) { - if (!line.trim()) continue; - - // 处理 SSE 格式: "data: {...}" - let jsonData = line; - if (line.startsWith('data: ')) { - jsonData = line.substring(6).trim(); + if (!line.trim()) { + continue } - if (!jsonData || jsonData === '[DONE]') continue; + // 处理 SSE 格式: "data: {...}" + let jsonData = line + if (line.startsWith('data: ')) { + jsonData = line.substring(6).trim() + } + + if (!jsonData || jsonData === '[DONE]') { + continue + } try { - const data = JSON.parse(jsonData); - + const data = JSON.parse(jsonData) + // 更新使用量统计 if (data.usageMetadata) { - totalUsage = data.usageMetadata; + totalUsage = data.usageMetadata } - + // 转换并发送响应 - const openaiResponse = convertGeminiResponse(data, model, true); + const openaiResponse = convertGeminiResponse(data, model, true) if (openaiResponse) { - yield `data: ${JSON.stringify(openaiResponse)}\n\n`; + yield `data: ${JSON.stringify(openaiResponse)}\n\n` } - + // 检查是否结束 if (data.candidates?.[0]?.finishReason === 'STOP') { // 记录使用量 if (apiKeyId && totalUsage.totalTokenCount > 0) { - await apiKeyService.recordUsage( - apiKeyId, - totalUsage.promptTokenCount || 0, // inputTokens - totalUsage.candidatesTokenCount || 0, // outputTokens - 0, // cacheCreateTokens (Gemini 没有这个概念) - 0, // cacheReadTokens (Gemini 没有这个概念) - model, - accountId - ).catch(error => { - logger.error('❌ Failed to record Gemini usage:', error); - }); + await apiKeyService + .recordUsage( + apiKeyId, + totalUsage.promptTokenCount || 0, // inputTokens + totalUsage.candidatesTokenCount || 0, // outputTokens + 0, // cacheCreateTokens (Gemini 没有这个概念) + 0, // cacheReadTokens (Gemini 没有这个概念) + model, + accountId + ) + .catch((error) => { + logger.error('❌ Failed to record Gemini usage:', error) + }) } - - yield 'data: [DONE]\n\n'; - return; + + yield 'data: [DONE]\n\n' + return } } catch (e) { - logger.debug('Error parsing JSON line:', e.message, 'Line:', jsonData); + logger.debug('Error parsing JSON line:', e.message, 'Line:', jsonData) } } } - + // 处理剩余的 buffer if (buffer.trim()) { try { - let jsonData = buffer.trim(); + let jsonData = buffer.trim() if (jsonData.startsWith('data: ')) { - jsonData = jsonData.substring(6).trim(); + jsonData = jsonData.substring(6).trim() } if (jsonData && jsonData !== '[DONE]') { - const data = JSON.parse(jsonData); - const openaiResponse = convertGeminiResponse(data, model, true); + const data = JSON.parse(jsonData) + const openaiResponse = convertGeminiResponse(data, model, true) if (openaiResponse) { - yield `data: ${JSON.stringify(openaiResponse)}\n\n`; + yield `data: ${JSON.stringify(openaiResponse)}\n\n` } } } catch (e) { - logger.debug('Error parsing final buffer:', e.message); + logger.debug('Error parsing final buffer:', e.message) } } - - yield 'data: [DONE]\n\n'; + + yield 'data: [DONE]\n\n' } catch (error) { // 检查是否是请求被中止 if (error.name === 'CanceledError' || error.code === 'ECONNABORTED') { - logger.info('Stream request was aborted by client'); + logger.info('Stream request was aborted by client') } else { - logger.error('Stream processing error:', error); + logger.error('Stream processing error:', error) yield `data: ${JSON.stringify({ error: { message: error.message, type: 'stream_error' } - })}\n\n`; + })}\n\n` } } } @@ -244,12 +259,12 @@ async function sendGeminiRequest({ }) { // 确保模型名称格式正确 if (!model.startsWith('models/')) { - model = `models/${model}`; + model = `models/${model}` } - + // 转换消息格式 - const { contents, systemInstruction } = convertMessagesToGemini(messages); - + const { contents, systemInstruction } = convertMessagesToGemini(messages) + // 构建请求体 const requestBody = { contents, @@ -258,160 +273,162 @@ async function sendGeminiRequest({ maxOutputTokens: maxTokens, candidateCount: 1 } - }; - - if (systemInstruction) { - requestBody.systemInstruction = { parts: [{ text: systemInstruction }] }; } - + + if (systemInstruction) { + requestBody.systemInstruction = { parts: [{ text: systemInstruction }] } + } + // 配置请求选项 - let apiUrl; + let apiUrl if (projectId) { // 使用项目特定的 URL 格式(Google Cloud/Workspace 账号) - apiUrl = `${GEMINI_API_BASE}/projects/${projectId}/locations/${location}/${model}:${stream ? 'streamGenerateContent' : 'generateContent'}?alt=sse`; - logger.debug(`Using project-specific URL with projectId: ${projectId}, location: ${location}`); + apiUrl = `${GEMINI_API_BASE}/projects/${projectId}/locations/${location}/${model}:${stream ? 'streamGenerateContent' : 'generateContent'}?alt=sse` + logger.debug(`Using project-specific URL with projectId: ${projectId}, location: ${location}`) } else { // 使用标准 URL 格式(个人 Google 账号) - apiUrl = `${GEMINI_API_BASE}/${model}:${stream ? 'streamGenerateContent' : 'generateContent'}?alt=sse`; - logger.debug('Using standard URL without projectId'); + apiUrl = `${GEMINI_API_BASE}/${model}:${stream ? 'streamGenerateContent' : 'generateContent'}?alt=sse` + logger.debug('Using standard URL without projectId') } - + const axiosConfig = { method: 'POST', url: apiUrl, headers: { - 'Authorization': `Bearer ${accessToken}`, + Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, data: requestBody, timeout: config.requestTimeout || 120000 - }; - - // 添加代理配置 - const proxyAgent = createProxyAgent(proxy); - if (proxyAgent) { - axiosConfig.httpsAgent = proxyAgent; - logger.debug('Using proxy for Gemini request'); } - + + // 添加代理配置 + const proxyAgent = createProxyAgent(proxy) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + logger.debug('Using proxy for Gemini request') + } + // 添加 AbortController 信号支持 if (signal) { - axiosConfig.signal = signal; - logger.debug('AbortController signal attached to request'); + axiosConfig.signal = signal + logger.debug('AbortController signal attached to request') } - + if (stream) { - axiosConfig.responseType = 'stream'; + axiosConfig.responseType = 'stream' } - + try { - logger.debug('Sending request to Gemini API'); - const response = await axios(axiosConfig); - + logger.debug('Sending request to Gemini API') + const response = await axios(axiosConfig) + if (stream) { - return handleStreamResponse(response, model, apiKeyId, accountId); + return handleStreamResponse(response, model, apiKeyId, accountId) } else { // 非流式响应 - const openaiResponse = convertGeminiResponse(response.data, model, false); - + const openaiResponse = convertGeminiResponse(response.data, model, false) + // 记录使用量 if (apiKeyId && openaiResponse.usage) { - await apiKeyService.recordUsage( - apiKeyId, - openaiResponse.usage.prompt_tokens || 0, - openaiResponse.usage.completion_tokens || 0, - 0, // cacheCreateTokens - 0, // cacheReadTokens - model, - accountId - ).catch(error => { - logger.error('❌ Failed to record Gemini usage:', error); - }); + await apiKeyService + .recordUsage( + apiKeyId, + openaiResponse.usage.prompt_tokens || 0, + openaiResponse.usage.completion_tokens || 0, + 0, // cacheCreateTokens + 0, // cacheReadTokens + model, + accountId + ) + .catch((error) => { + logger.error('❌ Failed to record Gemini usage:', error) + }) } - - return openaiResponse; + + return openaiResponse } } catch (error) { // 检查是否是请求被中止 if (error.name === 'CanceledError' || error.code === 'ECONNABORTED') { - logger.info('Gemini request was aborted by client'); - throw { - status: 499, - error: { - message: 'Request canceled by client', - type: 'canceled', - code: 'request_canceled' - } - }; + logger.info('Gemini request was aborted by client') + const err = new Error('Request canceled by client') + err.status = 499 + err.error = { + message: 'Request canceled by client', + type: 'canceled', + code: 'request_canceled' + } + throw err } - - logger.error('Gemini API request failed:', error.response?.data || error.message); - + + logger.error('Gemini API request failed:', error.response?.data || error.message) + // 转换错误格式 if (error.response) { - const geminiError = error.response.data?.error; - throw { - status: error.response.status, - error: { - message: geminiError?.message || 'Gemini API request failed', - type: geminiError?.code || 'api_error', - code: geminiError?.code - } - }; - } - - throw { - status: 500, - error: { - message: error.message, - type: 'network_error' + const geminiError = error.response.data?.error + const err = new Error(geminiError?.message || 'Gemini API request failed') + err.status = error.response.status + err.error = { + message: geminiError?.message || 'Gemini API request failed', + type: geminiError?.code || 'api_error', + code: geminiError?.code } - }; + throw err + } + + const err = new Error(error.message) + err.status = 500 + err.error = { + message: error.message, + type: 'network_error' + } + throw err } } // 获取可用模型列表 async function getAvailableModels(accessToken, proxy, projectId, location = 'us-central1') { - let apiUrl; + let apiUrl if (projectId) { // 使用项目特定的 URL 格式 - apiUrl = `${GEMINI_API_BASE}/projects/${projectId}/locations/${location}/models`; - logger.debug(`Fetching models with projectId: ${projectId}, location: ${location}`); + apiUrl = `${GEMINI_API_BASE}/projects/${projectId}/locations/${location}/models` + logger.debug(`Fetching models with projectId: ${projectId}, location: ${location}`) } else { // 使用标准 URL 格式 - apiUrl = `${GEMINI_API_BASE}/models`; - logger.debug('Fetching models without projectId'); + apiUrl = `${GEMINI_API_BASE}/models` + logger.debug('Fetching models without projectId') } - + const axiosConfig = { method: 'GET', url: apiUrl, headers: { - 'Authorization': `Bearer ${accessToken}` + Authorization: `Bearer ${accessToken}` }, timeout: 30000 - }; - - const proxyAgent = createProxyAgent(proxy); - if (proxyAgent) { - axiosConfig.httpsAgent = proxyAgent; } - + + const proxyAgent = createProxyAgent(proxy) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + } + try { - const response = await axios(axiosConfig); - const models = response.data.models || []; - + const response = await axios(axiosConfig) + const models = response.data.models || [] + // 转换为 OpenAI 格式 return models - .filter(model => model.supportedGenerationMethods?.includes('generateContent')) - .map(model => ({ + .filter((model) => model.supportedGenerationMethods?.includes('generateContent')) + .map((model) => ({ id: model.name.replace('models/', ''), object: 'model', created: Date.now() / 1000, owned_by: 'google' - })); + })) } catch (error) { - logger.error('Failed to get Gemini models:', error); + logger.error('Failed to get Gemini models:', error) // 返回默认模型列表 return [ { @@ -420,7 +437,7 @@ async function getAvailableModels(accessToken, proxy, projectId, location = 'us- created: Date.now() / 1000, owned_by: 'google' } - ]; + ] } } @@ -429,4 +446,4 @@ module.exports = { getAvailableModels, convertMessagesToGemini, convertGeminiResponse -}; \ No newline at end of file +} diff --git a/src/services/openaiToClaude.js b/src/services/openaiToClaude.js index 48131405..10c8ae24 100644 --- a/src/services/openaiToClaude.js +++ b/src/services/openaiToClaude.js @@ -3,17 +3,17 @@ * 处理 OpenAI API 格式与 Claude API 格式之间的转换 */ -const logger = require('../utils/logger'); +const logger = require('../utils/logger') class OpenAIToClaudeConverter { constructor() { // 停止原因映射 this.stopReasonMapping = { - 'end_turn': 'stop', - 'max_tokens': 'length', - 'stop_sequence': 'stop', - 'tool_use': 'tool_calls' - }; + end_turn: 'stop', + max_tokens: 'length', + stop_sequence: 'stop', + tool_use: 'tool_calls' + } } /** @@ -29,39 +29,39 @@ class OpenAIToClaudeConverter { temperature: openaiRequest.temperature, top_p: openaiRequest.top_p, stream: openaiRequest.stream || false - }; + } // Claude Code 必需的系统消息 - const claudeCodeSystemMessage = 'You are Claude Code, Anthropic\'s official CLI for Claude.'; - - claudeRequest.system = claudeCodeSystemMessage; + const claudeCodeSystemMessage = "You are Claude Code, Anthropic's official CLI for Claude." + + claudeRequest.system = claudeCodeSystemMessage // 处理停止序列 if (openaiRequest.stop) { - claudeRequest.stop_sequences = Array.isArray(openaiRequest.stop) - ? openaiRequest.stop - : [openaiRequest.stop]; + claudeRequest.stop_sequences = Array.isArray(openaiRequest.stop) + ? openaiRequest.stop + : [openaiRequest.stop] } // 处理工具调用 if (openaiRequest.tools) { - claudeRequest.tools = this._convertTools(openaiRequest.tools); + claudeRequest.tools = this._convertTools(openaiRequest.tools) if (openaiRequest.tool_choice) { - claudeRequest.tool_choice = this._convertToolChoice(openaiRequest.tool_choice); + claudeRequest.tool_choice = this._convertToolChoice(openaiRequest.tool_choice) } } // OpenAI 特有的参数已在转换过程中被忽略 // 包括: n, presence_penalty, frequency_penalty, logit_bias, user - + logger.debug('📝 Converted OpenAI request to Claude format:', { model: claudeRequest.model, messageCount: claudeRequest.messages.length, hasSystem: !!claudeRequest.system, stream: claudeRequest.stream - }); + }) - return claudeRequest; + return claudeRequest } /** @@ -71,28 +71,30 @@ class OpenAIToClaudeConverter { * @returns {Object} OpenAI 格式的响应 */ convertResponse(claudeResponse, requestModel) { - const timestamp = Math.floor(Date.now() / 1000); - + const timestamp = Math.floor(Date.now() / 1000) + const openaiResponse = { id: `chatcmpl-${this._generateId()}`, object: 'chat.completion', created: timestamp, model: requestModel || 'gpt-4', - choices: [{ - index: 0, - message: this._convertClaudeMessage(claudeResponse), - finish_reason: this._mapStopReason(claudeResponse.stop_reason) - }], + choices: [ + { + index: 0, + message: this._convertClaudeMessage(claudeResponse), + finish_reason: this._mapStopReason(claudeResponse.stop_reason) + } + ], usage: this._convertUsage(claudeResponse.usage) - }; + } logger.debug('📝 Converted Claude response to OpenAI format:', { responseId: openaiResponse.id, finishReason: openaiResponse.choices[0].finish_reason, usage: openaiResponse.usage - }); + }) - return openaiResponse; + return openaiResponse } /** @@ -103,36 +105,38 @@ class OpenAIToClaudeConverter { * @returns {String} OpenAI 格式的 SSE 数据块 */ convertStreamChunk(chunk, requestModel, sessionId) { - if (!chunk || chunk.trim() === '') return ''; - + if (!chunk || chunk.trim() === '') { + return '' + } + // 解析 SSE 数据 - const lines = chunk.split('\n'); - let convertedChunks = []; - let hasMessageStop = false; + const lines = chunk.split('\n') + const convertedChunks = [] + let hasMessageStop = false for (const line of lines) { if (line.startsWith('data: ')) { - const data = line.substring(6); + const data = line.substring(6) if (data === '[DONE]') { - convertedChunks.push('data: [DONE]\n\n'); - continue; + convertedChunks.push('data: [DONE]\n\n') + continue } try { - const claudeEvent = JSON.parse(data); - + const claudeEvent = JSON.parse(data) + // 检查是否是 message_stop 事件 if (claudeEvent.type === 'message_stop') { - hasMessageStop = true; + hasMessageStop = true } - - const openaiChunk = this._convertStreamEvent(claudeEvent, requestModel, sessionId); + + const openaiChunk = this._convertStreamEvent(claudeEvent, requestModel, sessionId) if (openaiChunk) { - convertedChunks.push(`data: ${JSON.stringify(openaiChunk)}\n\n`); + convertedChunks.push(`data: ${JSON.stringify(openaiChunk)}\n\n`) } } catch (e) { // 跳过无法解析的数据,不传递非JSON格式的行 - continue; + continue } } // 忽略 event: 行和空行,OpenAI 格式不包含这些 @@ -140,95 +144,102 @@ class OpenAIToClaudeConverter { // 如果收到 message_stop 事件,添加 [DONE] 标记 if (hasMessageStop) { - convertedChunks.push('data: [DONE]\n\n'); + convertedChunks.push('data: [DONE]\n\n') } - return convertedChunks.join(''); + return convertedChunks.join('') } - /** * 提取系统消息 */ _extractSystemMessage(messages) { - const systemMessages = messages.filter(msg => msg.role === 'system'); - if (systemMessages.length === 0) return null; - + const systemMessages = messages.filter((msg) => msg.role === 'system') + if (systemMessages.length === 0) { + return null + } + // 合并所有系统消息 - return systemMessages.map(msg => msg.content).join('\n\n'); + return systemMessages.map((msg) => msg.content).join('\n\n') } /** * 转换消息格式 */ _convertMessages(messages) { - const claudeMessages = []; - + const claudeMessages = [] + for (const msg of messages) { // 跳过系统消息(已经在 system 字段处理) - if (msg.role === 'system') continue; - - // 转换角色名称 - const role = msg.role === 'user' ? 'user' : 'assistant'; - - // 转换消息内容 - let content; - if (typeof msg.content === 'string') { - content = msg.content; - } else if (Array.isArray(msg.content)) { - // 处理多模态内容 - content = this._convertMultimodalContent(msg.content); - } else { - content = JSON.stringify(msg.content); + if (msg.role === 'system') { + continue } - + + // 转换角色名称 + const role = msg.role === 'user' ? 'user' : 'assistant' + + // 转换消息内容 + const { content: rawContent } = msg + let content + + if (typeof rawContent === 'string') { + content = rawContent + } else if (Array.isArray(rawContent)) { + // 处理多模态内容 + content = this._convertMultimodalContent(rawContent) + } else { + content = JSON.stringify(rawContent) + } + const claudeMsg = { - role: role, - content: content - }; - + role, + content + } + // 处理工具调用 if (msg.tool_calls) { - claudeMsg.content = this._convertToolCalls(msg.tool_calls); + claudeMsg.content = this._convertToolCalls(msg.tool_calls) } - + // 处理工具响应 if (msg.role === 'tool') { - claudeMsg.role = 'user'; - claudeMsg.content = [{ - type: 'tool_result', - tool_use_id: msg.tool_call_id, - content: msg.content - }]; + claudeMsg.role = 'user' + claudeMsg.content = [ + { + type: 'tool_result', + tool_use_id: msg.tool_call_id, + content: msg.content + } + ] } - - claudeMessages.push(claudeMsg); + + claudeMessages.push(claudeMsg) } - - return claudeMessages; + + return claudeMessages } /** * 转换多模态内容 */ _convertMultimodalContent(content) { - return content.map(item => { + return content.map((item) => { if (item.type === 'text') { return { type: 'text', text: item.text - }; + } } else if (item.type === 'image_url') { - const imageUrl = item.image_url.url; - + const imageUrl = item.image_url.url + // 检查是否是 base64 格式的图片 if (imageUrl.startsWith('data:')) { // 解析 data URL: data:image/jpeg;base64,/9j/4AAQ... - const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/); + const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/) if (matches) { - const mediaType = matches[1]; // e.g., 'image/jpeg', 'image/png' - const base64Data = matches[2]; - + const mediaType = matches[1] // e.g., 'image/jpeg', 'image/png' + const base64Data = matches[2] + return { type: 'image', source: { @@ -236,10 +247,10 @@ class OpenAIToClaudeConverter { media_type: mediaType, data: base64Data } - }; + } } else { // 如果格式不正确,尝试使用默认处理 - logger.warn('⚠️ Invalid base64 image format, using default parsing'); + logger.warn('⚠️ Invalid base64 image format, using default parsing') return { type: 'image', source: { @@ -247,60 +258,70 @@ class OpenAIToClaudeConverter { media_type: 'image/jpeg', data: imageUrl.split(',')[1] || '' } - }; + } } } else { // 如果是 URL 格式的图片,Claude 不支持直接 URL,需要报错 - logger.error('❌ URL images are not supported by Claude API, only base64 format is accepted'); - throw new Error('Claude API only supports base64 encoded images, not URLs. Please convert the image to base64 format.'); + logger.error( + '❌ URL images are not supported by Claude API, only base64 format is accepted' + ) + throw new Error( + 'Claude API only supports base64 encoded images, not URLs. Please convert the image to base64 format.' + ) } } - return item; - }); + return item + }) } /** * 转换工具定义 */ _convertTools(tools) { - return tools.map(tool => { + return tools.map((tool) => { if (tool.type === 'function') { return { name: tool.function.name, description: tool.function.description, input_schema: tool.function.parameters - }; + } } - return tool; - }); + return tool + }) } /** * 转换工具选择 */ _convertToolChoice(toolChoice) { - if (toolChoice === 'none') return { type: 'none' }; - if (toolChoice === 'auto') return { type: 'auto' }; - if (toolChoice === 'required') return { type: 'any' }; + if (toolChoice === 'none') { + return { type: 'none' } + } + if (toolChoice === 'auto') { + return { type: 'auto' } + } + if (toolChoice === 'required') { + return { type: 'any' } + } if (toolChoice.type === 'function') { return { type: 'tool', name: toolChoice.function.name - }; + } } - return { type: 'auto' }; + return { type: 'auto' } } /** * 转换工具调用 */ _convertToolCalls(toolCalls) { - return toolCalls.map(tc => ({ + return toolCalls.map((tc) => ({ type: 'tool_use', id: tc.id, name: tc.function.name, input: JSON.parse(tc.function.arguments) - })); + })) } /** @@ -310,20 +331,20 @@ class OpenAIToClaudeConverter { const message = { role: 'assistant', content: null - }; + } // 处理内容 if (claudeResponse.content) { if (typeof claudeResponse.content === 'string') { - message.content = claudeResponse.content; + message.content = claudeResponse.content } else if (Array.isArray(claudeResponse.content)) { // 提取文本内容和工具调用 - const textParts = []; - const toolCalls = []; - + const textParts = [] + const toolCalls = [] + for (const item of claudeResponse.content) { if (item.type === 'text') { - textParts.push(item.text); + textParts.push(item.text) } else if (item.type === 'tool_use') { toolCalls.push({ id: item.id, @@ -332,114 +353,121 @@ class OpenAIToClaudeConverter { name: item.name, arguments: JSON.stringify(item.input) } - }); + }) } } - - message.content = textParts.join('') || null; + + message.content = textParts.join('') || null if (toolCalls.length > 0) { - message.tool_calls = toolCalls; + message.tool_calls = toolCalls } } } - return message; + return message } /** * 转换停止原因 */ _mapStopReason(claudeReason) { - return this.stopReasonMapping[claudeReason] || 'stop'; + return this.stopReasonMapping[claudeReason] || 'stop' } /** * 转换使用统计 */ _convertUsage(claudeUsage) { - if (!claudeUsage) return undefined; - + if (!claudeUsage) { + return undefined + } + return { prompt_tokens: claudeUsage.input_tokens || 0, completion_tokens: claudeUsage.output_tokens || 0, total_tokens: (claudeUsage.input_tokens || 0) + (claudeUsage.output_tokens || 0) - }; + } } /** * 转换流式事件 */ _convertStreamEvent(event, requestModel, sessionId) { - const timestamp = Math.floor(Date.now() / 1000); + const timestamp = Math.floor(Date.now() / 1000) const baseChunk = { id: sessionId, object: 'chat.completion.chunk', created: timestamp, model: requestModel || 'gpt-4', - choices: [{ - index: 0, - delta: {}, - finish_reason: null - }] - }; + choices: [ + { + index: 0, + delta: {}, + finish_reason: null + } + ] + } // 根据事件类型处理 if (event.type === 'message_start') { // 处理消息开始事件,发送角色信息 - baseChunk.choices[0].delta.role = 'assistant'; - return baseChunk; + baseChunk.choices[0].delta.role = 'assistant' + return baseChunk } else if (event.type === 'content_block_start' && event.content_block) { if (event.content_block.type === 'text') { - baseChunk.choices[0].delta.content = event.content_block.text || ''; + baseChunk.choices[0].delta.content = event.content_block.text || '' } else if (event.content_block.type === 'tool_use') { // 开始工具调用 - baseChunk.choices[0].delta.tool_calls = [{ - index: event.index || 0, - id: event.content_block.id, - type: 'function', - function: { - name: event.content_block.name, - arguments: '' + baseChunk.choices[0].delta.tool_calls = [ + { + index: event.index || 0, + id: event.content_block.id, + type: 'function', + function: { + name: event.content_block.name, + arguments: '' + } } - }]; + ] } } else if (event.type === 'content_block_delta' && event.delta) { if (event.delta.type === 'text_delta') { - baseChunk.choices[0].delta.content = event.delta.text || ''; + baseChunk.choices[0].delta.content = event.delta.text || '' } else if (event.delta.type === 'input_json_delta') { // 工具调用参数的增量更新 - baseChunk.choices[0].delta.tool_calls = [{ - index: event.index || 0, - function: { - arguments: event.delta.partial_json || '' + baseChunk.choices[0].delta.tool_calls = [ + { + index: event.index || 0, + function: { + arguments: event.delta.partial_json || '' + } } - }]; + ] } } else if (event.type === 'message_delta' && event.delta) { if (event.delta.stop_reason) { - baseChunk.choices[0].finish_reason = this._mapStopReason(event.delta.stop_reason); + baseChunk.choices[0].finish_reason = this._mapStopReason(event.delta.stop_reason) } if (event.usage) { - baseChunk.usage = this._convertUsage(event.usage); + baseChunk.usage = this._convertUsage(event.usage) } } else if (event.type === 'message_stop') { // message_stop 事件不需要返回 chunk,[DONE] 标记会在 convertStreamChunk 中添加 - return null; + return null } else { // 忽略其他类型的事件 - return null; + return null } - return baseChunk; + return baseChunk } /** * 生成随机 ID */ _generateId() { - return Math.random().toString(36).substring(2, 15) + - Math.random().toString(36).substring(2, 15); + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) } } -module.exports = new OpenAIToClaudeConverter(); \ No newline at end of file +module.exports = new OpenAIToClaudeConverter() diff --git a/src/services/pricingService.js b/src/services/pricingService.js index be4ef21c..e31960b7 100644 --- a/src/services/pricingService.js +++ b/src/services/pricingService.js @@ -1,19 +1,25 @@ -const fs = require('fs'); -const path = require('path'); -const https = require('https'); -const logger = require('../utils/logger'); +const fs = require('fs') +const path = require('path') +const https = require('https') +const logger = require('../utils/logger') class PricingService { constructor() { - this.dataDir = path.join(process.cwd(), 'data'); - this.pricingFile = path.join(this.dataDir, 'model_pricing.json'); - this.pricingUrl = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'; - this.fallbackFile = path.join(process.cwd(), 'resources', 'model-pricing', 'model_prices_and_context_window.json'); - this.pricingData = null; - this.lastUpdated = null; - this.updateInterval = 24 * 60 * 60 * 1000; // 24小时 - this.fileWatcher = null; // 文件监听器 - this.reloadDebounceTimer = null; // 防抖定时器 + this.dataDir = path.join(process.cwd(), 'data') + this.pricingFile = path.join(this.dataDir, 'model_pricing.json') + this.pricingUrl = + 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json' + this.fallbackFile = path.join( + process.cwd(), + 'resources', + 'model-pricing', + 'model_prices_and_context_window.json' + ) + this.pricingData = null + this.lastUpdated = null + this.updateInterval = 24 * 60 * 60 * 1000 // 24小时 + this.fileWatcher = null // 文件监听器 + this.reloadDebounceTimer = null // 防抖定时器 } // 初始化价格服务 @@ -21,72 +27,74 @@ class PricingService { try { // 确保data目录存在 if (!fs.existsSync(this.dataDir)) { - fs.mkdirSync(this.dataDir, { recursive: true }); - logger.info('📁 Created data directory'); + fs.mkdirSync(this.dataDir, { recursive: true }) + logger.info('📁 Created data directory') } // 检查是否需要下载或更新价格数据 - await this.checkAndUpdatePricing(); - + await this.checkAndUpdatePricing() + // 设置定时更新 setInterval(() => { - this.checkAndUpdatePricing(); - }, this.updateInterval); + this.checkAndUpdatePricing() + }, this.updateInterval) // 设置文件监听器 - this.setupFileWatcher(); + this.setupFileWatcher() - logger.success('💰 Pricing service initialized successfully'); + logger.success('💰 Pricing service initialized successfully') } catch (error) { - logger.error('❌ Failed to initialize pricing service:', error); + logger.error('❌ Failed to initialize pricing service:', error) } } // 检查并更新价格数据 async checkAndUpdatePricing() { try { - const needsUpdate = this.needsUpdate(); - + const needsUpdate = this.needsUpdate() + if (needsUpdate) { - logger.info('🔄 Updating model pricing data...'); - await this.downloadPricingData(); + logger.info('🔄 Updating model pricing data...') + await this.downloadPricingData() } else { // 如果不需要更新,加载现有数据 - await this.loadPricingData(); + await this.loadPricingData() } } catch (error) { - logger.error('❌ Failed to check/update pricing:', error); + logger.error('❌ Failed to check/update pricing:', error) // 如果更新失败,尝试使用fallback - await this.useFallbackPricing(); + await this.useFallbackPricing() } } // 检查是否需要更新 needsUpdate() { if (!fs.existsSync(this.pricingFile)) { - logger.info('📋 Pricing file not found, will download'); - return true; + logger.info('📋 Pricing file not found, will download') + return true } - const stats = fs.statSync(this.pricingFile); - const fileAge = Date.now() - stats.mtime.getTime(); - + const stats = fs.statSync(this.pricingFile) + const fileAge = Date.now() - stats.mtime.getTime() + if (fileAge > this.updateInterval) { - logger.info(`📋 Pricing file is ${Math.round(fileAge / (60 * 60 * 1000))} hours old, will update`); - return true; + logger.info( + `📋 Pricing file is ${Math.round(fileAge / (60 * 60 * 1000))} hours old, will update` + ) + return true } - return false; + return false } // 下载价格数据 async downloadPricingData() { try { - await this._downloadFromRemote(); + await this._downloadFromRemote() } catch (downloadError) { - logger.warn(`⚠️ Failed to download pricing data: ${downloadError.message}`); - logger.info('📋 Using local fallback pricing data...'); - await this.useFallbackPricing(); + logger.warn(`⚠️ Failed to download pricing data: ${downloadError.message}`) + logger.info('📋 Using local fallback pricing data...') + await this.useFallbackPricing() } } @@ -95,67 +103,69 @@ class PricingService { return new Promise((resolve, reject) => { const request = https.get(this.pricingUrl, (response) => { if (response.statusCode !== 200) { - reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)); - return; + reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)) + return } - let data = ''; + let data = '' response.on('data', (chunk) => { - data += chunk; - }); + data += chunk + }) response.on('end', () => { try { - const jsonData = JSON.parse(data); - + const jsonData = JSON.parse(data) + // 保存到文件 - fs.writeFileSync(this.pricingFile, JSON.stringify(jsonData, null, 2)); - + fs.writeFileSync(this.pricingFile, JSON.stringify(jsonData, null, 2)) + // 更新内存中的数据 - this.pricingData = jsonData; - this.lastUpdated = new Date(); - - logger.success(`💰 Downloaded pricing data for ${Object.keys(jsonData).length} models`); - + this.pricingData = jsonData + this.lastUpdated = new Date() + + logger.success(`💰 Downloaded pricing data for ${Object.keys(jsonData).length} models`) + // 设置或重新设置文件监听器 - this.setupFileWatcher(); - - resolve(); + this.setupFileWatcher() + + resolve() } catch (error) { - reject(new Error(`Failed to parse pricing data: ${error.message}`)); + reject(new Error(`Failed to parse pricing data: ${error.message}`)) } - }); - }); + }) + }) request.on('error', (error) => { - reject(new Error(`Network error: ${error.message}`)); - }); + reject(new Error(`Network error: ${error.message}`)) + }) request.setTimeout(30000, () => { - request.destroy(); - reject(new Error('Download timeout after 30 seconds')); - }); - }); + request.destroy() + reject(new Error('Download timeout after 30 seconds')) + }) + }) } // 加载本地价格数据 async loadPricingData() { try { if (fs.existsSync(this.pricingFile)) { - const data = fs.readFileSync(this.pricingFile, 'utf8'); - this.pricingData = JSON.parse(data); - - const stats = fs.statSync(this.pricingFile); - this.lastUpdated = stats.mtime; - - logger.info(`💰 Loaded pricing data for ${Object.keys(this.pricingData).length} models from cache`); + const data = fs.readFileSync(this.pricingFile, 'utf8') + this.pricingData = JSON.parse(data) + + const stats = fs.statSync(this.pricingFile) + this.lastUpdated = stats.mtime + + logger.info( + `💰 Loaded pricing data for ${Object.keys(this.pricingData).length} models from cache` + ) } else { - logger.warn('💰 No pricing data file found, will use fallback'); - await this.useFallbackPricing(); + logger.warn('💰 No pricing data file found, will use fallback') + await this.useFallbackPricing() } } catch (error) { - logger.error('❌ Failed to load pricing data:', error); - await this.useFallbackPricing(); + logger.error('❌ Failed to load pricing data:', error) + await this.useFallbackPricing() } } @@ -163,89 +173,95 @@ class PricingService { async useFallbackPricing() { try { if (fs.existsSync(this.fallbackFile)) { - logger.info('📋 Copying fallback pricing data to data directory...'); - + logger.info('📋 Copying fallback pricing data to data directory...') + // 读取fallback文件 - const fallbackData = fs.readFileSync(this.fallbackFile, 'utf8'); - const jsonData = JSON.parse(fallbackData); - + const fallbackData = fs.readFileSync(this.fallbackFile, 'utf8') + const jsonData = JSON.parse(fallbackData) + // 保存到data目录 - fs.writeFileSync(this.pricingFile, JSON.stringify(jsonData, null, 2)); - + fs.writeFileSync(this.pricingFile, JSON.stringify(jsonData, null, 2)) + // 更新内存中的数据 - this.pricingData = jsonData; - this.lastUpdated = new Date(); - + this.pricingData = jsonData + this.lastUpdated = new Date() + // 设置或重新设置文件监听器 - this.setupFileWatcher(); - - logger.warn(`⚠️ Using fallback pricing data for ${Object.keys(jsonData).length} models`); - logger.info('💡 Note: This fallback data may be outdated. The system will try to update from the remote source on next check.'); + this.setupFileWatcher() + + logger.warn(`⚠️ Using fallback pricing data for ${Object.keys(jsonData).length} models`) + logger.info( + '💡 Note: This fallback data may be outdated. The system will try to update from the remote source on next check.' + ) } else { - logger.error('❌ Fallback pricing file not found at:', this.fallbackFile); - logger.error('❌ Please ensure the resources/model-pricing directory exists with the pricing file'); - this.pricingData = {}; + logger.error('❌ Fallback pricing file not found at:', this.fallbackFile) + logger.error( + '❌ Please ensure the resources/model-pricing directory exists with the pricing file' + ) + this.pricingData = {} } } catch (error) { - logger.error('❌ Failed to use fallback pricing data:', error); - this.pricingData = {}; + logger.error('❌ Failed to use fallback pricing data:', error) + this.pricingData = {} } } // 获取模型价格信息 getModelPricing(modelName) { if (!this.pricingData || !modelName) { - return null; + return null } // 尝试直接匹配 if (this.pricingData[modelName]) { - return this.pricingData[modelName]; + return this.pricingData[modelName] } // 对于Bedrock区域前缀模型(如 us.anthropic.claude-sonnet-4-20250514-v1:0), // 尝试去掉区域前缀进行匹配 if (modelName.includes('.anthropic.') || modelName.includes('.claude')) { // 提取不带区域前缀的模型名 - const withoutRegion = modelName.replace(/^(us|eu|apac)\./, ''); + const withoutRegion = modelName.replace(/^(us|eu|apac)\./, '') if (this.pricingData[withoutRegion]) { - logger.debug(`💰 Found pricing for ${modelName} by removing region prefix: ${withoutRegion}`); - return this.pricingData[withoutRegion]; + logger.debug( + `💰 Found pricing for ${modelName} by removing region prefix: ${withoutRegion}` + ) + return this.pricingData[withoutRegion] } } // 尝试模糊匹配(处理版本号等变化) - const normalizedModel = modelName.toLowerCase().replace(/[_-]/g, ''); - + const normalizedModel = modelName.toLowerCase().replace(/[_-]/g, '') + for (const [key, value] of Object.entries(this.pricingData)) { - const normalizedKey = key.toLowerCase().replace(/[_-]/g, ''); + const normalizedKey = key.toLowerCase().replace(/[_-]/g, '') if (normalizedKey.includes(normalizedModel) || normalizedModel.includes(normalizedKey)) { - logger.debug(`💰 Found pricing for ${modelName} using fuzzy match: ${key}`); - return value; + logger.debug(`💰 Found pricing for ${modelName} using fuzzy match: ${key}`) + return value } } // 对于Bedrock模型,尝试更智能的匹配 if (modelName.includes('anthropic.claude')) { // 提取核心模型名部分(去掉区域和前缀) - const coreModel = modelName.replace(/^(us|eu|apac)\./, '').replace('anthropic.', ''); - + const coreModel = modelName.replace(/^(us|eu|apac)\./, '').replace('anthropic.', '') + for (const [key, value] of Object.entries(this.pricingData)) { if (key.includes(coreModel) || key.replace('anthropic.', '').includes(coreModel)) { - logger.debug(`💰 Found pricing for ${modelName} using Bedrock core model match: ${key}`); - return value; + logger.debug(`💰 Found pricing for ${modelName} using Bedrock core model match: ${key}`) + return value } } } - logger.debug(`💰 No pricing found for model: ${modelName}`); - return null; + logger.debug(`💰 No pricing found for model: ${modelName}`) + return null } // 计算使用费用 calculateCost(usage, modelName) { - const pricing = this.getModelPricing(modelName); - + const pricing = this.getModelPricing(modelName) + if (!pricing) { return { inputCost: 0, @@ -254,13 +270,15 @@ class PricingService { cacheReadCost: 0, totalCost: 0, hasPricing: false - }; + } } - const inputCost = (usage.input_tokens || 0) * (pricing.input_cost_per_token || 0); - const outputCost = (usage.output_tokens || 0) * (pricing.output_cost_per_token || 0); - const cacheCreateCost = (usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0); - const cacheReadCost = (usage.cache_read_input_tokens || 0) * (pricing.cache_read_input_token_cost || 0); + const inputCost = (usage.input_tokens || 0) * (pricing.input_cost_per_token || 0) + const outputCost = (usage.output_tokens || 0) * (pricing.output_cost_per_token || 0) + const cacheCreateCost = + (usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0) + const cacheReadCost = + (usage.cache_read_input_tokens || 0) * (pricing.cache_read_input_token_cost || 0) return { inputCost, @@ -275,16 +293,24 @@ class PricingService { cacheCreate: pricing.cache_creation_input_token_cost || 0, cacheRead: pricing.cache_read_input_token_cost || 0 } - }; + } } // 格式化价格显示 formatCost(cost) { - if (cost === 0) return '$0.000000'; - if (cost < 0.000001) return `$${cost.toExponential(2)}`; - if (cost < 0.01) return `$${cost.toFixed(6)}`; - if (cost < 1) return `$${cost.toFixed(4)}`; - return `$${cost.toFixed(2)}`; + if (cost === 0) { + return '$0.000000' + } + if (cost < 0.000001) { + return `$${cost.toExponential(2)}` + } + if (cost < 0.01) { + return `$${cost.toFixed(6)}` + } + if (cost < 1) { + return `$${cost.toFixed(4)}` + } + return `$${cost.toFixed(2)}` } // 获取服务状态 @@ -293,23 +319,25 @@ class PricingService { initialized: this.pricingData !== null, lastUpdated: this.lastUpdated, modelCount: this.pricingData ? Object.keys(this.pricingData).length : 0, - nextUpdate: this.lastUpdated ? new Date(this.lastUpdated.getTime() + this.updateInterval) : null - }; + nextUpdate: this.lastUpdated + ? new Date(this.lastUpdated.getTime() + this.updateInterval) + : null + } } // 强制更新价格数据 async forceUpdate() { try { - await this._downloadFromRemote(); - return { success: true, message: 'Pricing data updated successfully' }; + await this._downloadFromRemote() + return { success: true, message: 'Pricing data updated successfully' } } catch (error) { - logger.error('❌ Force update failed:', error); - logger.info('📋 Force update failed, using fallback pricing data...'); - await this.useFallbackPricing(); - return { - success: false, - message: `Download failed: ${error.message}. Using fallback pricing data instead.` - }; + logger.error('❌ Force update failed:', error) + logger.info('📋 Force update failed, using fallback pricing data...') + await this.useFallbackPricing() + return { + success: false, + message: `Download failed: ${error.message}. Using fallback pricing data instead.` + } } } @@ -318,43 +346,45 @@ class PricingService { try { // 如果已有监听器,先关闭 if (this.fileWatcher) { - this.fileWatcher.close(); - this.fileWatcher = null; + this.fileWatcher.close() + this.fileWatcher = null } // 只有文件存在时才设置监听器 if (!fs.existsSync(this.pricingFile)) { - logger.debug('💰 Pricing file does not exist yet, skipping file watcher setup'); - return; + logger.debug('💰 Pricing file does not exist yet, skipping file watcher setup') + return } // 使用 fs.watchFile 作为更可靠的文件监听方式 // 它使用轮询,虽然性能稍差,但更可靠 - const watchOptions = { - persistent: true, + const watchOptions = { + persistent: true, interval: 60000 // 每60秒检查一次 - }; - + } + // 记录初始的修改时间 - let lastMtime = fs.statSync(this.pricingFile).mtimeMs; - + let lastMtime = fs.statSync(this.pricingFile).mtimeMs + fs.watchFile(this.pricingFile, watchOptions, (curr, _prev) => { // 检查文件是否真的被修改了(不仅仅是访问) if (curr.mtimeMs !== lastMtime) { - lastMtime = curr.mtimeMs; - logger.debug(`💰 Detected change in pricing file (mtime: ${new Date(curr.mtime).toISOString()})`); - this.handleFileChange(); + lastMtime = curr.mtimeMs + logger.debug( + `💰 Detected change in pricing file (mtime: ${new Date(curr.mtime).toISOString()})` + ) + this.handleFileChange() } - }); - + }) + // 保存引用以便清理 this.fileWatcher = { close: () => fs.unwatchFile(this.pricingFile) - }; + } - logger.info('👁️ File watcher set up for model_pricing.json (polling every 60s)'); + logger.info('👁️ File watcher set up for model_pricing.json (polling every 60s)') } catch (error) { - logger.error('❌ Failed to setup file watcher:', error); + logger.error('❌ Failed to setup file watcher:', error) } } @@ -362,14 +392,14 @@ class PricingService { handleFileChange() { // 清除之前的定时器 if (this.reloadDebounceTimer) { - clearTimeout(this.reloadDebounceTimer); + clearTimeout(this.reloadDebounceTimer) } // 设置新的定时器(防抖500ms) this.reloadDebounceTimer = setTimeout(async () => { - logger.info('🔄 Reloading pricing data due to file change...'); - await this.reloadPricingData(); - }, 500); + logger.info('🔄 Reloading pricing data due to file change...') + await this.reloadPricingData() + }, 500) } // 重新加载价格数据 @@ -377,55 +407,57 @@ class PricingService { try { // 验证文件是否存在 if (!fs.existsSync(this.pricingFile)) { - logger.warn('💰 Pricing file was deleted, using fallback'); - await this.useFallbackPricing(); + logger.warn('💰 Pricing file was deleted, using fallback') + await this.useFallbackPricing() // 重新设置文件监听器(fallback会创建新文件) - this.setupFileWatcher(); - return; + this.setupFileWatcher() + return } // 读取文件内容 - const data = fs.readFileSync(this.pricingFile, 'utf8'); - + const data = fs.readFileSync(this.pricingFile, 'utf8') + // 尝试解析JSON - const jsonData = JSON.parse(data); - + const jsonData = JSON.parse(data) + // 验证数据结构 if (typeof jsonData !== 'object' || Object.keys(jsonData).length === 0) { - throw new Error('Invalid pricing data structure'); + throw new Error('Invalid pricing data structure') } // 更新内存中的数据 - this.pricingData = jsonData; - this.lastUpdated = new Date(); - - const modelCount = Object.keys(jsonData).length; - logger.success(`💰 Reloaded pricing data for ${modelCount} models from file`); - + this.pricingData = jsonData + this.lastUpdated = new Date() + + const modelCount = Object.keys(jsonData).length + logger.success(`💰 Reloaded pricing data for ${modelCount} models from file`) + // 显示一些统计信息 - const claudeModels = Object.keys(jsonData).filter(k => k.includes('claude')).length; - const gptModels = Object.keys(jsonData).filter(k => k.includes('gpt')).length; - const geminiModels = Object.keys(jsonData).filter(k => k.includes('gemini')).length; - - logger.debug(`💰 Model breakdown: Claude=${claudeModels}, GPT=${gptModels}, Gemini=${geminiModels}`); + const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length + const gptModels = Object.keys(jsonData).filter((k) => k.includes('gpt')).length + const geminiModels = Object.keys(jsonData).filter((k) => k.includes('gemini')).length + + logger.debug( + `💰 Model breakdown: Claude=${claudeModels}, GPT=${gptModels}, Gemini=${geminiModels}` + ) } catch (error) { - logger.error('❌ Failed to reload pricing data:', error); - logger.warn('💰 Keeping existing pricing data in memory'); + logger.error('❌ Failed to reload pricing data:', error) + logger.warn('💰 Keeping existing pricing data in memory') } } // 清理资源 cleanup() { if (this.fileWatcher) { - this.fileWatcher.close(); - this.fileWatcher = null; - logger.debug('💰 File watcher closed'); + this.fileWatcher.close() + this.fileWatcher = null + logger.debug('💰 File watcher closed') } if (this.reloadDebounceTimer) { - clearTimeout(this.reloadDebounceTimer); - this.reloadDebounceTimer = null; + clearTimeout(this.reloadDebounceTimer) + this.reloadDebounceTimer = null } } } -module.exports = new PricingService(); \ No newline at end of file +module.exports = new PricingService() diff --git a/src/services/tokenRefreshService.js b/src/services/tokenRefreshService.js index f825062e..48e1cc7f 100644 --- a/src/services/tokenRefreshService.js +++ b/src/services/tokenRefreshService.js @@ -1,6 +1,6 @@ -const redis = require('../models/redis'); -const logger = require('../utils/logger'); -const { v4: uuidv4 } = require('uuid'); +const redis = require('../models/redis') +const logger = require('../utils/logger') +const { v4: uuidv4 } = require('uuid') /** * Token 刷新锁服务 @@ -8,30 +8,29 @@ const { v4: uuidv4 } = require('uuid'); */ class TokenRefreshService { constructor() { - this.lockTTL = 60; // 锁的TTL: 60秒(token刷新通常在30秒内完成) - this.lockValue = new Map(); // 存储每个锁的唯一值 + this.lockTTL = 60 // 锁的TTL: 60秒(token刷新通常在30秒内完成) + this.lockValue = new Map() // 存储每个锁的唯一值 } - /** * 获取分布式锁 * 使用唯一标识符作为值,避免误释放其他进程的锁 */ async acquireLock(lockKey) { try { - const client = redis.getClientSafe(); - const lockId = uuidv4(); - const result = await client.set(lockKey, lockId, 'NX', 'EX', this.lockTTL); - + const client = redis.getClientSafe() + const lockId = uuidv4() + const result = await client.set(lockKey, lockId, 'NX', 'EX', this.lockTTL) + if (result === 'OK') { - this.lockValue.set(lockKey, lockId); - logger.debug(`🔒 Acquired lock ${lockKey} with ID ${lockId}, TTL: ${this.lockTTL}s`); - return true; + this.lockValue.set(lockKey, lockId) + logger.debug(`🔒 Acquired lock ${lockKey} with ID ${lockId}, TTL: ${this.lockTTL}s`) + return true } - return false; + return false } catch (error) { - logger.error(`Failed to acquire lock ${lockKey}:`, error); - return false; + logger.error(`Failed to acquire lock ${lockKey}:`, error) + return false } } @@ -41,12 +40,12 @@ class TokenRefreshService { */ async releaseLock(lockKey) { try { - const client = redis.getClientSafe(); - const lockId = this.lockValue.get(lockKey); - + const client = redis.getClientSafe() + const lockId = this.lockValue.get(lockKey) + if (!lockId) { - logger.warn(`⚠️ No lock ID found for ${lockKey}, skipping release`); - return; + logger.warn(`⚠️ No lock ID found for ${lockKey}, skipping release`) + return } // Lua 脚本:只有当值匹配时才删除 @@ -56,18 +55,18 @@ class TokenRefreshService { else return 0 end - `; - - const result = await client.eval(luaScript, 1, lockKey, lockId); - + ` + + const result = await client.eval(luaScript, 1, lockKey, lockId) + if (result === 1) { - this.lockValue.delete(lockKey); - logger.debug(`🔓 Released lock ${lockKey} with ID ${lockId}`); + this.lockValue.delete(lockKey) + logger.debug(`🔓 Released lock ${lockKey} with ID ${lockId}`) } else { - logger.warn(`⚠️ Lock ${lockKey} was not released - value mismatch or already expired`); + logger.warn(`⚠️ Lock ${lockKey} was not released - value mismatch or already expired`) } } catch (error) { - logger.error(`Failed to release lock ${lockKey}:`, error); + logger.error(`Failed to release lock ${lockKey}:`, error) } } @@ -78,8 +77,8 @@ class TokenRefreshService { * @returns {Promise} 是否成功获取锁 */ async acquireRefreshLock(accountId, platform = 'claude') { - const lockKey = `token_refresh_lock:${platform}:${accountId}`; - return await this.acquireLock(lockKey); + const lockKey = `token_refresh_lock:${platform}:${accountId}` + return await this.acquireLock(lockKey) } /** @@ -88,8 +87,8 @@ class TokenRefreshService { * @param {string} platform - 平台类型 (claude/gemini) */ async releaseRefreshLock(accountId, platform = 'claude') { - const lockKey = `token_refresh_lock:${platform}:${accountId}`; - await this.releaseLock(lockKey); + const lockKey = `token_refresh_lock:${platform}:${accountId}` + await this.releaseLock(lockKey) } /** @@ -99,14 +98,14 @@ class TokenRefreshService { * @returns {Promise} 锁是否存在 */ async isRefreshLocked(accountId, platform = 'claude') { - const lockKey = `token_refresh_lock:${platform}:${accountId}`; + const lockKey = `token_refresh_lock:${platform}:${accountId}` try { - const client = redis.getClientSafe(); - const exists = await client.exists(lockKey); - return exists === 1; + const client = redis.getClientSafe() + const exists = await client.exists(lockKey) + return exists === 1 } catch (error) { - logger.error(`Failed to check lock status ${lockKey}:`, error); - return false; + logger.error(`Failed to check lock status ${lockKey}:`, error) + return false } } @@ -117,14 +116,14 @@ class TokenRefreshService { * @returns {Promise} 剩余秒数,-1表示锁不存在 */ async getLockTTL(accountId, platform = 'claude') { - const lockKey = `token_refresh_lock:${platform}:${accountId}`; + const lockKey = `token_refresh_lock:${platform}:${accountId}` try { - const client = redis.getClientSafe(); - const ttl = await client.ttl(lockKey); - return ttl; + const client = redis.getClientSafe() + const ttl = await client.ttl(lockKey) + return ttl } catch (error) { - logger.error(`Failed to get lock TTL ${lockKey}:`, error); - return -1; + logger.error(`Failed to get lock TTL ${lockKey}:`, error) + return -1 } } @@ -133,12 +132,12 @@ class TokenRefreshService { * 在进程退出时调用,避免内存泄漏 */ cleanup() { - this.lockValue.clear(); - logger.info('🧹 Cleaned up local lock records'); + this.lockValue.clear() + logger.info('🧹 Cleaned up local lock records') } } // 创建单例实例 -const tokenRefreshService = new TokenRefreshService(); +const tokenRefreshService = new TokenRefreshService() -module.exports = tokenRefreshService; \ No newline at end of file +module.exports = tokenRefreshService diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index f2a002da..e76bba6e 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -1,23 +1,23 @@ -const claudeAccountService = require('./claudeAccountService'); -const claudeConsoleAccountService = require('./claudeConsoleAccountService'); -const bedrockAccountService = require('./bedrockAccountService'); -const accountGroupService = require('./accountGroupService'); -const redis = require('../models/redis'); -const logger = require('../utils/logger'); +const claudeAccountService = require('./claudeAccountService') +const claudeConsoleAccountService = require('./claudeConsoleAccountService') +const bedrockAccountService = require('./bedrockAccountService') +const accountGroupService = require('./accountGroupService') +const redis = require('../models/redis') +const logger = require('../utils/logger') class UnifiedClaudeScheduler { constructor() { - this.SESSION_MAPPING_PREFIX = 'unified_claude_session_mapping:'; + this.SESSION_MAPPING_PREFIX = 'unified_claude_session_mapping:' } // 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值) _isSchedulable(schedulable) { // 如果是 undefined 或 null,默认为可调度 if (schedulable === undefined || schedulable === null) { - return true; + return true } // 明确设置为 false(布尔值)或 'false'(字符串)时不可调度 - return schedulable !== false && schedulable !== 'false'; + return schedulable !== false && schedulable !== 'false' } // 🎯 统一调度Claude账号(官方和Console) @@ -27,177 +27,248 @@ class UnifiedClaudeScheduler { if (apiKeyData.claudeAccountId) { // 检查是否是分组 if (apiKeyData.claudeAccountId.startsWith('group:')) { - const groupId = apiKeyData.claudeAccountId.replace('group:', ''); - logger.info(`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group`); - return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel); + const groupId = apiKeyData.claudeAccountId.replace('group:', '') + logger.info( + `🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group` + ) + return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel) } - + // 普通专属账户 - const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId); + const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { - logger.info(`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`); + logger.info( + `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` + ) return { accountId: apiKeyData.claudeAccountId, accountType: 'claude-official' - }; + } } else { - logger.warn(`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available, falling back to pool`); + logger.warn( + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available, falling back to pool` + ) } } - + // 2. 检查Claude Console账户绑定 if (apiKeyData.claudeConsoleAccountId) { - const boundConsoleAccount = await claudeConsoleAccountService.getAccount(apiKeyData.claudeConsoleAccountId); - if (boundConsoleAccount && boundConsoleAccount.isActive === true && boundConsoleAccount.status === 'active') { - logger.info(`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`); + const boundConsoleAccount = await claudeConsoleAccountService.getAccount( + apiKeyData.claudeConsoleAccountId + ) + if ( + boundConsoleAccount && + boundConsoleAccount.isActive === true && + boundConsoleAccount.status === 'active' + ) { + logger.info( + `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}` + ) return { accountId: apiKeyData.claudeConsoleAccountId, accountType: 'claude-console' - }; + } } else { - logger.warn(`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available, falling back to pool`); + logger.warn( + `⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available, falling back to pool` + ) } } // 3. 检查Bedrock账户绑定 if (apiKeyData.bedrockAccountId) { - const boundBedrockAccountResult = await bedrockAccountService.getAccount(apiKeyData.bedrockAccountId); + const boundBedrockAccountResult = await bedrockAccountService.getAccount( + apiKeyData.bedrockAccountId + ) if (boundBedrockAccountResult.success && boundBedrockAccountResult.data.isActive === true) { - logger.info(`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`); + logger.info( + `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}` + ) return { accountId: apiKeyData.bedrockAccountId, accountType: 'bedrock' - }; + } } else { - logger.warn(`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available, falling back to pool`); + logger.warn( + `⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available, falling back to pool` + ) } } // 如果有会话哈希,检查是否有已映射的账户 if (sessionHash) { - const mappedAccount = await this._getSessionMapping(sessionHash); + const mappedAccount = await this._getSessionMapping(sessionHash) if (mappedAccount) { // 验证映射的账户是否仍然可用 - const isAvailable = await this._isAccountAvailable(mappedAccount.accountId, mappedAccount.accountType); + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType + ) if (isAvailable) { - logger.info(`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`); - return mappedAccount; + logger.info( + `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` + ) + return mappedAccount } else { - logger.warn(`⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account`); - await this._deleteSessionMapping(sessionHash); + logger.warn( + `⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account` + ) + await this._deleteSessionMapping(sessionHash) } } } // 获取所有可用账户(传递请求的模型进行过滤) - const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel); - + const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel) + if (availableAccounts.length === 0) { // 提供更详细的错误信息 if (requestedModel) { - throw new Error(`No available Claude accounts support the requested model: ${requestedModel}`); + throw new Error( + `No available Claude accounts support the requested model: ${requestedModel}` + ) } else { - throw new Error('No available Claude accounts (neither official nor console)'); + throw new Error('No available Claude accounts (neither official nor console)') } } // 按优先级和最后使用时间排序 - const sortedAccounts = this._sortAccountsByPriority(availableAccounts); + const sortedAccounts = this._sortAccountsByPriority(availableAccounts) // 选择第一个账户 - const selectedAccount = sortedAccounts[0]; - + const selectedAccount = sortedAccounts[0] + // 如果有会话哈希,建立新的映射 if (sessionHash) { - await this._setSessionMapping(sessionHash, selectedAccount.accountId, selectedAccount.accountType); - logger.info(`🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`); + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` + ) } - logger.info(`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}`); - + logger.info( + `🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}` + ) + return { accountId: selectedAccount.accountId, accountType: selectedAccount.accountType - }; + } } catch (error) { - logger.error('❌ Failed to select account for API key:', error); - throw error; + logger.error('❌ Failed to select account for API key:', error) + throw error } } // 📋 获取所有可用账户(合并官方和Console) async _getAllAvailableAccounts(apiKeyData, requestedModel = null) { - const availableAccounts = []; + const availableAccounts = [] // 如果API Key绑定了专属账户,优先返回 // 1. 检查Claude OAuth账户绑定 if (apiKeyData.claudeAccountId) { - const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId); - if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error' && boundAccount.status !== 'blocked') { - const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id); + const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) + if ( + boundAccount && + boundAccount.isActive === 'true' && + boundAccount.status !== 'error' && + boundAccount.status !== 'blocked' + ) { + const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) if (!isRateLimited) { - logger.info(`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId})`); - return [{ - ...boundAccount, - accountId: boundAccount.id, - accountType: 'claude-official', - priority: parseInt(boundAccount.priority) || 50, - lastUsedAt: boundAccount.lastUsedAt || '0' - }]; + logger.info( + `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId})` + ) + return [ + { + ...boundAccount, + accountId: boundAccount.id, + accountType: 'claude-official', + priority: parseInt(boundAccount.priority) || 50, + lastUsedAt: boundAccount.lastUsedAt || '0' + } + ] } } else { - logger.warn(`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available`); + logger.warn(`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available`) } } - + // 2. 检查Claude Console账户绑定 if (apiKeyData.claudeConsoleAccountId) { - const boundConsoleAccount = await claudeConsoleAccountService.getAccount(apiKeyData.claudeConsoleAccountId); - if (boundConsoleAccount && boundConsoleAccount.isActive === true && boundConsoleAccount.status === 'active') { - const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(boundConsoleAccount.id); + const boundConsoleAccount = await claudeConsoleAccountService.getAccount( + apiKeyData.claudeConsoleAccountId + ) + if ( + boundConsoleAccount && + boundConsoleAccount.isActive === true && + boundConsoleAccount.status === 'active' + ) { + const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited( + boundConsoleAccount.id + ) if (!isRateLimited) { - logger.info(`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId})`); - return [{ - ...boundConsoleAccount, - accountId: boundConsoleAccount.id, - accountType: 'claude-console', - priority: parseInt(boundConsoleAccount.priority) || 50, - lastUsedAt: boundConsoleAccount.lastUsedAt || '0' - }]; + logger.info( + `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId})` + ) + return [ + { + ...boundConsoleAccount, + accountId: boundConsoleAccount.id, + accountType: 'claude-console', + priority: parseInt(boundConsoleAccount.priority) || 50, + lastUsedAt: boundConsoleAccount.lastUsedAt || '0' + } + ] } } else { - logger.warn(`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available`); + logger.warn( + `⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available` + ) } } // 3. 检查Bedrock账户绑定 if (apiKeyData.bedrockAccountId) { - const boundBedrockAccountResult = await bedrockAccountService.getAccount(apiKeyData.bedrockAccountId); + const boundBedrockAccountResult = await bedrockAccountService.getAccount( + apiKeyData.bedrockAccountId + ) if (boundBedrockAccountResult.success && boundBedrockAccountResult.data.isActive === true) { - logger.info(`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})`); - return [{ - ...boundBedrockAccountResult.data, - accountId: boundBedrockAccountResult.data.id, - accountType: 'bedrock', - priority: parseInt(boundBedrockAccountResult.data.priority) || 50, - lastUsedAt: boundBedrockAccountResult.data.lastUsedAt || '0' - }]; + logger.info( + `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})` + ) + return [ + { + ...boundBedrockAccountResult.data, + accountId: boundBedrockAccountResult.data.id, + accountType: 'bedrock', + priority: parseInt(boundBedrockAccountResult.data.priority) || 50, + lastUsedAt: boundBedrockAccountResult.data.lastUsedAt || '0' + } + ] } else { - logger.warn(`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available`); + logger.warn(`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available`) } } // 获取官方Claude账户(共享池) - const claudeAccounts = await redis.getAllClaudeAccounts(); + const claudeAccounts = await redis.getAllClaudeAccounts() for (const account of claudeAccounts) { - if (account.isActive === 'true' && - account.status !== 'error' && - account.status !== 'blocked' && - (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 - this._isSchedulable(account.schedulable)) { // 检查是否可调度 - + if ( + account.isActive === 'true' && + account.status !== 'error' && + account.status !== 'blocked' && + (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 + this._isSchedulable(account.schedulable) + ) { + // 检查是否可调度 + // 检查是否被限流 - const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id); + const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id) if (!isRateLimited) { availableAccounts.push({ ...account, @@ -205,44 +276,59 @@ class UnifiedClaudeScheduler { accountType: 'claude-official', priority: parseInt(account.priority) || 50, // 默认优先级50 lastUsedAt: account.lastUsedAt || '0' - }); + }) } } } // 获取Claude Console账户 - const consoleAccounts = await claudeConsoleAccountService.getAllAccounts(); - logger.info(`📋 Found ${consoleAccounts.length} total Claude Console accounts`); - + const consoleAccounts = await claudeConsoleAccountService.getAllAccounts() + logger.info(`📋 Found ${consoleAccounts.length} total Claude Console accounts`) + for (const account of consoleAccounts) { - logger.info(`🔍 Checking Claude Console account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`); - + logger.info( + `🔍 Checking Claude Console account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + // 注意:getAllAccounts返回的isActive是布尔值 - if (account.isActive === true && - account.status === 'active' && - account.accountType === 'shared' && - this._isSchedulable(account.schedulable)) { // 检查是否可调度 - + if ( + account.isActive === true && + account.status === 'active' && + account.accountType === 'shared' && + this._isSchedulable(account.schedulable) + ) { + // 检查是否可调度 + // 检查模型支持(如果有请求的模型) if (requestedModel && account.supportedModels) { // 兼容旧格式(数组)和新格式(对象) if (Array.isArray(account.supportedModels)) { // 旧格式:数组 - if (account.supportedModels.length > 0 && !account.supportedModels.includes(requestedModel)) { - logger.info(`🚫 Claude Console account ${account.name} does not support model ${requestedModel}`); - continue; + if ( + account.supportedModels.length > 0 && + !account.supportedModels.includes(requestedModel) + ) { + logger.info( + `🚫 Claude Console account ${account.name} does not support model ${requestedModel}` + ) + continue } } else if (typeof account.supportedModels === 'object') { // 新格式:映射表 - if (Object.keys(account.supportedModels).length > 0 && !claudeConsoleAccountService.isModelSupported(account.supportedModels, requestedModel)) { - logger.info(`🚫 Claude Console account ${account.name} does not support model ${requestedModel}`); - continue; + if ( + Object.keys(account.supportedModels).length > 0 && + !claudeConsoleAccountService.isModelSupported(account.supportedModels, requestedModel) + ) { + logger.info( + `🚫 Claude Console account ${account.name} does not support model ${requestedModel}` + ) + continue } } } - + // 检查是否被限流 - const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id); + const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id) if (!isRateLimited) { availableAccounts.push({ ...account, @@ -250,45 +336,60 @@ class UnifiedClaudeScheduler { accountType: 'claude-console', priority: parseInt(account.priority) || 50, lastUsedAt: account.lastUsedAt || '0' - }); - logger.info(`✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})`); + }) + logger.info( + `✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})` + ) } else { - logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`); + logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`) } } else { - logger.info(`❌ Claude Console account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`); + logger.info( + `❌ Claude Console account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) } } // 获取Bedrock账户(共享池) - const bedrockAccountsResult = await bedrockAccountService.getAllAccounts(); + const bedrockAccountsResult = await bedrockAccountService.getAllAccounts() if (bedrockAccountsResult.success) { - const bedrockAccounts = bedrockAccountsResult.data; - logger.info(`📋 Found ${bedrockAccounts.length} total Bedrock accounts`); - + const bedrockAccounts = bedrockAccountsResult.data + logger.info(`📋 Found ${bedrockAccounts.length} total Bedrock accounts`) + for (const account of bedrockAccounts) { - logger.info(`🔍 Checking Bedrock account: ${account.name} - isActive: ${account.isActive}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`); - - if (account.isActive === true && - account.accountType === 'shared' && - this._isSchedulable(account.schedulable)) { // 检查是否可调度 - + logger.info( + `🔍 Checking Bedrock account: ${account.name} - isActive: ${account.isActive}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + + if ( + account.isActive === true && + account.accountType === 'shared' && + this._isSchedulable(account.schedulable) + ) { + // 检查是否可调度 + availableAccounts.push({ ...account, accountId: account.id, accountType: 'bedrock', priority: parseInt(account.priority) || 50, lastUsedAt: account.lastUsedAt || '0' - }); - logger.info(`✅ Added Bedrock account to available pool: ${account.name} (priority: ${account.priority})`); + }) + logger.info( + `✅ Added Bedrock account to available pool: ${account.name} (priority: ${account.priority})` + ) } else { - logger.info(`❌ Bedrock account ${account.name} not eligible - isActive: ${account.isActive}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`); + logger.info( + `❌ Bedrock account ${account.name} not eligible - isActive: ${account.isActive}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) } } } - - logger.info(`📊 Total available accounts: ${availableAccounts.length} (Claude: ${availableAccounts.filter(a => a.accountType === 'claude-official').length}, Console: ${availableAccounts.filter(a => a.accountType === 'claude-console').length}, Bedrock: ${availableAccounts.filter(a => a.accountType === 'bedrock').length})`); - return availableAccounts; + + logger.info( + `📊 Total available accounts: ${availableAccounts.length} (Claude: ${availableAccounts.filter((a) => a.accountType === 'claude-official').length}, Console: ${availableAccounts.filter((a) => a.accountType === 'claude-console').length}, Bedrock: ${availableAccounts.filter((a) => a.accountType === 'bedrock').length})` + ) + return availableAccounts } // 🔢 按优先级和最后使用时间排序账户 @@ -296,115 +397,123 @@ class UnifiedClaudeScheduler { return accounts.sort((a, b) => { // 首先按优先级排序(数字越小优先级越高) if (a.priority !== b.priority) { - return a.priority - b.priority; + return a.priority - b.priority } - + // 优先级相同时,按最后使用时间排序(最久未使用的优先) - const aLastUsed = new Date(a.lastUsedAt || 0).getTime(); - const bLastUsed = new Date(b.lastUsedAt || 0).getTime(); - return aLastUsed - bLastUsed; - }); + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed + }) } // 🔍 检查账户是否可用 async _isAccountAvailable(accountId, accountType) { try { if (accountType === 'claude-official') { - const account = await redis.getClaudeAccount(accountId); + const account = await redis.getClaudeAccount(accountId) if (!account || account.isActive !== 'true' || account.status === 'error') { - return false; + return false } // 检查是否可调度 if (!this._isSchedulable(account.schedulable)) { - logger.info(`🚫 Account ${accountId} is not schedulable`); - return false; + logger.info(`🚫 Account ${accountId} is not schedulable`) + return false } - return !(await claudeAccountService.isAccountRateLimited(accountId)); + return !(await claudeAccountService.isAccountRateLimited(accountId)) } else if (accountType === 'claude-console') { - const account = await claudeConsoleAccountService.getAccount(accountId); + const account = await claudeConsoleAccountService.getAccount(accountId) if (!account || !account.isActive || account.status !== 'active') { - return false; + return false } // 检查是否可调度 if (!this._isSchedulable(account.schedulable)) { - logger.info(`🚫 Claude Console account ${accountId} is not schedulable`); - return false; + logger.info(`🚫 Claude Console account ${accountId} is not schedulable`) + return false } - return !(await claudeConsoleAccountService.isAccountRateLimited(accountId)); + return !(await claudeConsoleAccountService.isAccountRateLimited(accountId)) } else if (accountType === 'bedrock') { - const accountResult = await bedrockAccountService.getAccount(accountId); + const accountResult = await bedrockAccountService.getAccount(accountId) if (!accountResult.success || !accountResult.data.isActive) { - return false; + return false } // 检查是否可调度 if (!this._isSchedulable(accountResult.data.schedulable)) { - logger.info(`🚫 Bedrock account ${accountId} is not schedulable`); - return false; + logger.info(`🚫 Bedrock account ${accountId} is not schedulable`) + return false } // Bedrock账户暂不需要限流检查,因为AWS管理限流 - return true; + return true } - return false; + return false } catch (error) { - logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error); - return false; + logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error) + return false } } // 🔗 获取会话映射 async _getSessionMapping(sessionHash) { - const client = redis.getClientSafe(); - const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`); - + const client = redis.getClientSafe() + const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + if (mappingData) { try { - return JSON.parse(mappingData); + return JSON.parse(mappingData) } catch (error) { - logger.warn('⚠️ Failed to parse session mapping:', error); - return null; + logger.warn('⚠️ Failed to parse session mapping:', error) + return null } } - - return null; + + return null } // 💾 设置会话映射 async _setSessionMapping(sessionHash, accountId, accountType) { - const client = redis.getClientSafe(); - const mappingData = JSON.stringify({ accountId, accountType }); - + const client = redis.getClientSafe() + const mappingData = JSON.stringify({ accountId, accountType }) + // 设置1小时过期 - await client.setex( - `${this.SESSION_MAPPING_PREFIX}${sessionHash}`, - 3600, - mappingData - ); + await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, mappingData) } // 🗑️ 删除会话映射 async _deleteSessionMapping(sessionHash) { - const client = redis.getClientSafe(); - await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`); + const client = redis.getClientSafe() + await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) } // 🚫 标记账户为限流状态 - async markAccountRateLimited(accountId, accountType, sessionHash = null, rateLimitResetTimestamp = null) { + async markAccountRateLimited( + accountId, + accountType, + sessionHash = null, + rateLimitResetTimestamp = null + ) { try { if (accountType === 'claude-official') { - await claudeAccountService.markAccountRateLimited(accountId, sessionHash, rateLimitResetTimestamp); + await claudeAccountService.markAccountRateLimited( + accountId, + sessionHash, + rateLimitResetTimestamp + ) } else if (accountType === 'claude-console') { - await claudeConsoleAccountService.markAccountRateLimited(accountId); + await claudeConsoleAccountService.markAccountRateLimited(accountId) } // 删除会话映射 if (sessionHash) { - await this._deleteSessionMapping(sessionHash); + await this._deleteSessionMapping(sessionHash) } - return { success: true }; + return { success: true } } catch (error) { - logger.error(`❌ Failed to mark account as rate limited: ${accountId} (${accountType})`, error); - throw error; + logger.error( + `❌ Failed to mark account as rate limited: ${accountId} (${accountType})`, + error + ) + throw error } } @@ -412,15 +521,18 @@ class UnifiedClaudeScheduler { async removeAccountRateLimit(accountId, accountType) { try { if (accountType === 'claude-official') { - await claudeAccountService.removeAccountRateLimit(accountId); + await claudeAccountService.removeAccountRateLimit(accountId) } else if (accountType === 'claude-console') { - await claudeConsoleAccountService.removeAccountRateLimit(accountId); + await claudeConsoleAccountService.removeAccountRateLimit(accountId) } - return { success: true }; + return { success: true } } catch (error) { - logger.error(`❌ Failed to remove rate limit for account: ${accountId} (${accountType})`, error); - throw error; + logger.error( + `❌ Failed to remove rate limit for account: ${accountId} (${accountType})`, + error + ) + throw error } } @@ -428,25 +540,25 @@ class UnifiedClaudeScheduler { async isAccountRateLimited(accountId, accountType) { try { if (accountType === 'claude-official') { - return await claudeAccountService.isAccountRateLimited(accountId); + return await claudeAccountService.isAccountRateLimited(accountId) } else if (accountType === 'claude-console') { - return await claudeConsoleAccountService.isAccountRateLimited(accountId); + return await claudeConsoleAccountService.isAccountRateLimited(accountId) } - return false; + return false } catch (error) { - logger.error(`❌ Failed to check rate limit status: ${accountId} (${accountType})`, error); - return false; + logger.error(`❌ Failed to check rate limit status: ${accountId} (${accountType})`, error) + return false } } // 🚫 标记Claude Console账户为封锁状态(模型不支持) async blockConsoleAccount(accountId, reason) { try { - await claudeConsoleAccountService.blockAccount(accountId, reason); - return { success: true }; + await claudeConsoleAccountService.blockAccount(accountId, reason) + return { success: true } } catch (error) { - logger.error(`❌ Failed to block console account: ${accountId}`, error); - throw error; + logger.error(`❌ Failed to block console account: ${accountId}`, error) + throw error } } @@ -454,127 +566,149 @@ class UnifiedClaudeScheduler { async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null) { try { // 获取分组信息 - const group = await accountGroupService.getGroup(groupId); + const group = await accountGroupService.getGroup(groupId) if (!group) { - throw new Error(`Group ${groupId} not found`); + throw new Error(`Group ${groupId} not found`) } - logger.info(`👥 Selecting account from group: ${group.name} (${group.platform})`); + logger.info(`👥 Selecting account from group: ${group.name} (${group.platform})`) // 如果有会话哈希,检查是否有已映射的账户 if (sessionHash) { - const mappedAccount = await this._getSessionMapping(sessionHash); + const mappedAccount = await this._getSessionMapping(sessionHash) if (mappedAccount) { // 验证映射的账户是否属于这个分组 - const memberIds = await accountGroupService.getGroupMembers(groupId); + const memberIds = await accountGroupService.getGroupMembers(groupId) if (memberIds.includes(mappedAccount.accountId)) { - const isAvailable = await this._isAccountAvailable(mappedAccount.accountId, mappedAccount.accountType); + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType + ) if (isAvailable) { - logger.info(`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`); - return mappedAccount; + logger.info( + `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` + ) + return mappedAccount } } // 如果映射的账户不可用或不在分组中,删除映射 - await this._deleteSessionMapping(sessionHash); + await this._deleteSessionMapping(sessionHash) } } // 获取分组内的所有账户 - const memberIds = await accountGroupService.getGroupMembers(groupId); + const memberIds = await accountGroupService.getGroupMembers(groupId) if (memberIds.length === 0) { - throw new Error(`Group ${group.name} has no members`); + throw new Error(`Group ${group.name} has no members`) } - const availableAccounts = []; + const availableAccounts = [] // 获取所有成员账户的详细信息 for (const memberId of memberIds) { - let account = null; - let accountType = null; + let account = null + let accountType = null // 根据平台类型获取账户 if (group.platform === 'claude') { // 先尝试官方账户 - account = await redis.getClaudeAccount(memberId); + account = await redis.getClaudeAccount(memberId) if (account?.id) { - accountType = 'claude-official'; + accountType = 'claude-official' } else { // 尝试Console账户 - account = await claudeConsoleAccountService.getAccount(memberId); + account = await claudeConsoleAccountService.getAccount(memberId) if (account) { - accountType = 'claude-console'; + accountType = 'claude-console' } } } else if (group.platform === 'gemini') { // Gemini暂时不支持,预留接口 - logger.warn('⚠️ Gemini group scheduling not yet implemented'); - continue; + logger.warn('⚠️ Gemini group scheduling not yet implemented') + continue } if (!account) { - logger.warn(`⚠️ Account ${memberId} not found in group ${group.name}`); - continue; + logger.warn(`⚠️ Account ${memberId} not found in group ${group.name}`) + continue } // 检查账户是否可用 - const isActive = accountType === 'claude-official' - ? account.isActive === 'true' - : account.isActive === true; - - const status = accountType === 'claude-official' - ? account.status !== 'error' && account.status !== 'blocked' - : account.status === 'active'; + const isActive = + accountType === 'claude-official' + ? account.isActive === 'true' + : account.isActive === true + + const status = + accountType === 'claude-official' + ? account.status !== 'error' && account.status !== 'blocked' + : account.status === 'active' if (isActive && status && this._isSchedulable(account.schedulable)) { // 检查模型支持(Console账户) - if (accountType === 'claude-console' && requestedModel && account.supportedModels && account.supportedModels.length > 0) { + if ( + accountType === 'claude-console' && + requestedModel && + account.supportedModels && + account.supportedModels.length > 0 + ) { if (!account.supportedModels.includes(requestedModel)) { - logger.info(`🚫 Account ${account.name} in group does not support model ${requestedModel}`); - continue; + logger.info( + `🚫 Account ${account.name} in group does not support model ${requestedModel}` + ) + continue } } // 检查是否被限流 - const isRateLimited = await this.isAccountRateLimited(account.id, accountType); + const isRateLimited = await this.isAccountRateLimited(account.id, accountType) if (!isRateLimited) { availableAccounts.push({ ...account, accountId: account.id, - accountType: accountType, + accountType, priority: parseInt(account.priority) || 50, lastUsedAt: account.lastUsedAt || '0' - }); + }) } } } if (availableAccounts.length === 0) { - throw new Error(`No available accounts in group ${group.name}`); + throw new Error(`No available accounts in group ${group.name}`) } // 使用现有的优先级排序逻辑 - const sortedAccounts = this._sortAccountsByPriority(availableAccounts); + const sortedAccounts = this._sortAccountsByPriority(availableAccounts) // 选择第一个账户 - const selectedAccount = sortedAccounts[0]; + const selectedAccount = sortedAccounts[0] // 如果有会话哈希,建立新的映射 if (sessionHash) { - await this._setSessionMapping(sessionHash, selectedAccount.accountId, selectedAccount.accountType); - logger.info(`🎯 Created new sticky session mapping in group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`); + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping in group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` + ) } - logger.info(`🎯 Selected account from group ${group.name}: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority}`); - + logger.info( + `🎯 Selected account from group ${group.name}: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority}` + ) + return { accountId: selectedAccount.accountId, accountType: selectedAccount.accountType - }; + } } catch (error) { - logger.error(`❌ Failed to select account from group ${groupId}:`, error); - throw error; + logger.error(`❌ Failed to select account from group ${groupId}:`, error) + throw error } } } -module.exports = new UnifiedClaudeScheduler(); \ No newline at end of file +module.exports = new UnifiedClaudeScheduler() diff --git a/src/services/unifiedGeminiScheduler.js b/src/services/unifiedGeminiScheduler.js index 92dc4868..8ff6d5e5 100644 --- a/src/services/unifiedGeminiScheduler.js +++ b/src/services/unifiedGeminiScheduler.js @@ -1,21 +1,21 @@ -const geminiAccountService = require('./geminiAccountService'); -const accountGroupService = require('./accountGroupService'); -const redis = require('../models/redis'); -const logger = require('../utils/logger'); +const geminiAccountService = require('./geminiAccountService') +const accountGroupService = require('./accountGroupService') +const redis = require('../models/redis') +const logger = require('../utils/logger') class UnifiedGeminiScheduler { constructor() { - this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:'; + this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:' } // 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值) _isSchedulable(schedulable) { // 如果是 undefined 或 null,默认为可调度 if (schedulable === undefined || schedulable === null) { - return true; + return true } // 明确设置为 false(布尔值)或 'false'(字符串)时不可调度 - return schedulable !== false && schedulable !== 'false'; + return schedulable !== false && schedulable !== 'false' } // 🎯 统一调度Gemini账号 @@ -25,143 +25,183 @@ class UnifiedGeminiScheduler { if (apiKeyData.geminiAccountId) { // 检查是否是分组 if (apiKeyData.geminiAccountId.startsWith('group:')) { - const groupId = apiKeyData.geminiAccountId.replace('group:', ''); - logger.info(`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group`); - return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData); + const groupId = apiKeyData.geminiAccountId.replace('group:', '') + logger.info( + `🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group` + ) + return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData) } - + // 普通专属账户 - const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId); + const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId) if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { - logger.info(`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}`); + logger.info( + `🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}` + ) return { accountId: apiKeyData.geminiAccountId, accountType: 'gemini' - }; + } } else { - logger.warn(`⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available, falling back to pool`); + logger.warn( + `⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available, falling back to pool` + ) } } // 如果有会话哈希,检查是否有已映射的账户 if (sessionHash) { - const mappedAccount = await this._getSessionMapping(sessionHash); + const mappedAccount = await this._getSessionMapping(sessionHash) if (mappedAccount) { // 验证映射的账户是否仍然可用 - const isAvailable = await this._isAccountAvailable(mappedAccount.accountId, mappedAccount.accountType); + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType + ) if (isAvailable) { - logger.info(`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`); - return mappedAccount; + logger.info( + `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` + ) + return mappedAccount } else { - logger.warn(`⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account`); - await this._deleteSessionMapping(sessionHash); + logger.warn( + `⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account` + ) + await this._deleteSessionMapping(sessionHash) } } } // 获取所有可用账户 - const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel); - + const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel) + if (availableAccounts.length === 0) { // 提供更详细的错误信息 if (requestedModel) { - throw new Error(`No available Gemini accounts support the requested model: ${requestedModel}`); + throw new Error( + `No available Gemini accounts support the requested model: ${requestedModel}` + ) } else { - throw new Error('No available Gemini accounts'); + throw new Error('No available Gemini accounts') } } // 按优先级和最后使用时间排序 - const sortedAccounts = this._sortAccountsByPriority(availableAccounts); + const sortedAccounts = this._sortAccountsByPriority(availableAccounts) // 选择第一个账户 - const selectedAccount = sortedAccounts[0]; - + const selectedAccount = sortedAccounts[0] + // 如果有会话哈希,建立新的映射 if (sessionHash) { - await this._setSessionMapping(sessionHash, selectedAccount.accountId, selectedAccount.accountType); - logger.info(`🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`); + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` + ) } - logger.info(`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}`); - + logger.info( + `🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}` + ) + return { accountId: selectedAccount.accountId, accountType: selectedAccount.accountType - }; + } } catch (error) { - logger.error('❌ Failed to select account for API key:', error); - throw error; + logger.error('❌ Failed to select account for API key:', error) + throw error } } // 📋 获取所有可用账户 async _getAllAvailableAccounts(apiKeyData, requestedModel = null) { - const availableAccounts = []; + const availableAccounts = [] // 如果API Key绑定了专属账户,优先返回 if (apiKeyData.geminiAccountId) { - const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId); + const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId) if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { - const isRateLimited = await this.isAccountRateLimited(boundAccount.id); + const isRateLimited = await this.isAccountRateLimited(boundAccount.id) if (!isRateLimited) { // 检查模型支持 - if (requestedModel && boundAccount.supportedModels && boundAccount.supportedModels.length > 0) { + if ( + requestedModel && + boundAccount.supportedModels && + boundAccount.supportedModels.length > 0 + ) { // 处理可能带有 models/ 前缀的模型名 - const normalizedModel = requestedModel.replace('models/', ''); - const modelSupported = boundAccount.supportedModels.some(model => - model.replace('models/', '') === normalizedModel - ); + const normalizedModel = requestedModel.replace('models/', '') + const modelSupported = boundAccount.supportedModels.some( + (model) => model.replace('models/', '') === normalizedModel + ) if (!modelSupported) { - logger.warn(`⚠️ Bound Gemini account ${boundAccount.name} does not support model ${requestedModel}`); - return availableAccounts; + logger.warn( + `⚠️ Bound Gemini account ${boundAccount.name} does not support model ${requestedModel}` + ) + return availableAccounts } } - - logger.info(`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId})`); - return [{ - ...boundAccount, - accountId: boundAccount.id, - accountType: 'gemini', - priority: parseInt(boundAccount.priority) || 50, - lastUsedAt: boundAccount.lastUsedAt || '0' - }]; + + logger.info( + `🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId})` + ) + return [ + { + ...boundAccount, + accountId: boundAccount.id, + accountType: 'gemini', + priority: parseInt(boundAccount.priority) || 50, + lastUsedAt: boundAccount.lastUsedAt || '0' + } + ] } } else { - logger.warn(`⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available`); + logger.warn(`⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available`) } } // 获取所有Gemini账户(共享池) - const geminiAccounts = await geminiAccountService.getAllAccounts(); + const geminiAccounts = await geminiAccountService.getAllAccounts() for (const account of geminiAccounts) { - if (account.isActive === 'true' && - account.status !== 'error' && - (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 - this._isSchedulable(account.schedulable)) { // 检查是否可调度 - + if ( + account.isActive === 'true' && + account.status !== 'error' && + (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 + this._isSchedulable(account.schedulable) + ) { + // 检查是否可调度 + // 检查token是否过期 - const isExpired = geminiAccountService.isTokenExpired(account); + const isExpired = geminiAccountService.isTokenExpired(account) if (isExpired && !account.refreshToken) { - logger.warn(`⚠️ Gemini account ${account.name} token expired and no refresh token available`); - continue; + logger.warn( + `⚠️ Gemini account ${account.name} token expired and no refresh token available` + ) + continue } - + // 检查模型支持 if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { // 处理可能带有 models/ 前缀的模型名 - const normalizedModel = requestedModel.replace('models/', ''); - const modelSupported = account.supportedModels.some(model => - model.replace('models/', '') === normalizedModel - ); + const normalizedModel = requestedModel.replace('models/', '') + const modelSupported = account.supportedModels.some( + (model) => model.replace('models/', '') === normalizedModel + ) if (!modelSupported) { - logger.debug(`⏭️ Skipping Gemini account ${account.name} - doesn't support model ${requestedModel}`); - continue; + logger.debug( + `⏭️ Skipping Gemini account ${account.name} - doesn't support model ${requestedModel}` + ) + continue } } - + // 检查是否被限流 - const isRateLimited = await this.isAccountRateLimited(account.id); + const isRateLimited = await this.isAccountRateLimited(account.id) if (!isRateLimited) { availableAccounts.push({ ...account, @@ -169,13 +209,13 @@ class UnifiedGeminiScheduler { accountType: 'gemini', priority: parseInt(account.priority) || 50, // 默认优先级50 lastUsedAt: account.lastUsedAt || '0' - }); + }) } } } - - logger.info(`📊 Total available Gemini accounts: ${availableAccounts.length}`); - return availableAccounts; + + logger.info(`📊 Total available Gemini accounts: ${availableAccounts.length}`) + return availableAccounts } // 🔢 按优先级和最后使用时间排序账户 @@ -183,90 +223,89 @@ class UnifiedGeminiScheduler { return accounts.sort((a, b) => { // 首先按优先级排序(数字越小优先级越高) if (a.priority !== b.priority) { - return a.priority - b.priority; + return a.priority - b.priority } - + // 优先级相同时,按最后使用时间排序(最久未使用的优先) - const aLastUsed = new Date(a.lastUsedAt || 0).getTime(); - const bLastUsed = new Date(b.lastUsedAt || 0).getTime(); - return aLastUsed - bLastUsed; - }); + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed + }) } // 🔍 检查账户是否可用 async _isAccountAvailable(accountId, accountType) { try { if (accountType === 'gemini') { - const account = await geminiAccountService.getAccount(accountId); + const account = await geminiAccountService.getAccount(accountId) if (!account || account.isActive !== 'true' || account.status === 'error') { - return false; + return false } // 检查是否可调度 if (!this._isSchedulable(account.schedulable)) { - logger.info(`🚫 Gemini account ${accountId} is not schedulable`); - return false; + logger.info(`🚫 Gemini account ${accountId} is not schedulable`) + return false } - return !(await this.isAccountRateLimited(accountId)); + return !(await this.isAccountRateLimited(accountId)) } - return false; + return false } catch (error) { - logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error); - return false; + logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error) + return false } } // 🔗 获取会话映射 async _getSessionMapping(sessionHash) { - const client = redis.getClientSafe(); - const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`); - + const client = redis.getClientSafe() + const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + if (mappingData) { try { - return JSON.parse(mappingData); + return JSON.parse(mappingData) } catch (error) { - logger.warn('⚠️ Failed to parse session mapping:', error); - return null; + logger.warn('⚠️ Failed to parse session mapping:', error) + return null } } - - return null; + + return null } // 💾 设置会话映射 async _setSessionMapping(sessionHash, accountId, accountType) { - const client = redis.getClientSafe(); - const mappingData = JSON.stringify({ accountId, accountType }); - + const client = redis.getClientSafe() + const mappingData = JSON.stringify({ accountId, accountType }) + // 设置1小时过期 - await client.setex( - `${this.SESSION_MAPPING_PREFIX}${sessionHash}`, - 3600, - mappingData - ); + await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, mappingData) } // 🗑️ 删除会话映射 async _deleteSessionMapping(sessionHash) { - const client = redis.getClientSafe(); - await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`); + const client = redis.getClientSafe() + await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) } // 🚫 标记账户为限流状态 async markAccountRateLimited(accountId, accountType, sessionHash = null) { try { if (accountType === 'gemini') { - await geminiAccountService.setAccountRateLimited(accountId, true); + await geminiAccountService.setAccountRateLimited(accountId, true) } // 删除会话映射 if (sessionHash) { - await this._deleteSessionMapping(sessionHash); + await this._deleteSessionMapping(sessionHash) } - return { success: true }; + return { success: true } } catch (error) { - logger.error(`❌ Failed to mark account as rate limited: ${accountId} (${accountType})`, error); - throw error; + logger.error( + `❌ Failed to mark account as rate limited: ${accountId} (${accountType})`, + error + ) + throw error } } @@ -274,33 +313,38 @@ class UnifiedGeminiScheduler { async removeAccountRateLimit(accountId, accountType) { try { if (accountType === 'gemini') { - await geminiAccountService.setAccountRateLimited(accountId, false); + await geminiAccountService.setAccountRateLimited(accountId, false) } - return { success: true }; + return { success: true } } catch (error) { - logger.error(`❌ Failed to remove rate limit for account: ${accountId} (${accountType})`, error); - throw error; + logger.error( + `❌ Failed to remove rate limit for account: ${accountId} (${accountType})`, + error + ) + throw error } } // 🔍 检查账户是否处于限流状态 async isAccountRateLimited(accountId) { try { - const account = await geminiAccountService.getAccount(accountId); - if (!account) return false; - - if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { - const limitedAt = new Date(account.rateLimitedAt).getTime(); - const now = Date.now(); - const limitDuration = 60 * 60 * 1000; // 1小时 - - return now < (limitedAt + limitDuration); + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return false } - return false; + + if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { + const limitedAt = new Date(account.rateLimitedAt).getTime() + const now = Date.now() + const limitDuration = 60 * 60 * 1000 // 1小时 + + return now < limitedAt + limitDuration + } + return false } catch (error) { - logger.error(`❌ Failed to check rate limit status: ${accountId}`, error); - return false; + logger.error(`❌ Failed to check rate limit status: ${accountId}`, error) + return false } } @@ -308,79 +352,89 @@ class UnifiedGeminiScheduler { async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null) { try { // 获取分组信息 - const group = await accountGroupService.getGroup(groupId); + const group = await accountGroupService.getGroup(groupId) if (!group) { - throw new Error(`Group ${groupId} not found`); - } - - if (group.platform !== 'gemini') { - throw new Error(`Group ${group.name} is not a Gemini group`); + throw new Error(`Group ${groupId} not found`) } - logger.info(`👥 Selecting account from Gemini group: ${group.name}`); + if (group.platform !== 'gemini') { + throw new Error(`Group ${group.name} is not a Gemini group`) + } + + logger.info(`👥 Selecting account from Gemini group: ${group.name}`) // 如果有会话哈希,检查是否有已映射的账户 if (sessionHash) { - const mappedAccount = await this._getSessionMapping(sessionHash); + const mappedAccount = await this._getSessionMapping(sessionHash) if (mappedAccount) { // 验证映射的账户是否属于这个分组 - const memberIds = await accountGroupService.getGroupMembers(groupId); + const memberIds = await accountGroupService.getGroupMembers(groupId) if (memberIds.includes(mappedAccount.accountId)) { - const isAvailable = await this._isAccountAvailable(mappedAccount.accountId, mappedAccount.accountType); + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType + ) if (isAvailable) { - logger.info(`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`); - return mappedAccount; + logger.info( + `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` + ) + return mappedAccount } } // 如果映射的账户不可用或不在分组中,删除映射 - await this._deleteSessionMapping(sessionHash); + await this._deleteSessionMapping(sessionHash) } } // 获取分组内的所有账户 - const memberIds = await accountGroupService.getGroupMembers(groupId); + const memberIds = await accountGroupService.getGroupMembers(groupId) if (memberIds.length === 0) { - throw new Error(`Group ${group.name} has no members`); + throw new Error(`Group ${group.name} has no members`) } - const availableAccounts = []; + const availableAccounts = [] // 获取所有成员账户的详细信息 for (const memberId of memberIds) { - const account = await geminiAccountService.getAccount(memberId); - + const account = await geminiAccountService.getAccount(memberId) + if (!account) { - logger.warn(`⚠️ Gemini account ${memberId} not found in group ${group.name}`); - continue; + logger.warn(`⚠️ Gemini account ${memberId} not found in group ${group.name}`) + continue } // 检查账户是否可用 - if (account.isActive === 'true' && - account.status !== 'error' && - this._isSchedulable(account.schedulable)) { - + if ( + account.isActive === 'true' && + account.status !== 'error' && + this._isSchedulable(account.schedulable) + ) { // 检查token是否过期 - const isExpired = geminiAccountService.isTokenExpired(account); + const isExpired = geminiAccountService.isTokenExpired(account) if (isExpired && !account.refreshToken) { - logger.warn(`⚠️ Gemini account ${account.name} in group token expired and no refresh token available`); - continue; + logger.warn( + `⚠️ Gemini account ${account.name} in group token expired and no refresh token available` + ) + continue } // 检查模型支持 if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { // 处理可能带有 models/ 前缀的模型名 - const normalizedModel = requestedModel.replace('models/', ''); - const modelSupported = account.supportedModels.some(model => - model.replace('models/', '') === normalizedModel - ); + const normalizedModel = requestedModel.replace('models/', '') + const modelSupported = account.supportedModels.some( + (model) => model.replace('models/', '') === normalizedModel + ) if (!modelSupported) { - logger.debug(`⏭️ Skipping Gemini account ${account.name} in group - doesn't support model ${requestedModel}`); - continue; + logger.debug( + `⏭️ Skipping Gemini account ${account.name} in group - doesn't support model ${requestedModel}` + ) + continue } } - + // 检查是否被限流 - const isRateLimited = await this.isAccountRateLimited(account.id); + const isRateLimited = await this.isAccountRateLimited(account.id) if (!isRateLimited) { availableAccounts.push({ ...account, @@ -388,38 +442,46 @@ class UnifiedGeminiScheduler { accountType: 'gemini', priority: parseInt(account.priority) || 50, lastUsedAt: account.lastUsedAt || '0' - }); + }) } } } if (availableAccounts.length === 0) { - throw new Error(`No available accounts in Gemini group ${group.name}`); + throw new Error(`No available accounts in Gemini group ${group.name}`) } // 使用现有的优先级排序逻辑 - const sortedAccounts = this._sortAccountsByPriority(availableAccounts); + const sortedAccounts = this._sortAccountsByPriority(availableAccounts) // 选择第一个账户 - const selectedAccount = sortedAccounts[0]; + const selectedAccount = sortedAccounts[0] // 如果有会话哈希,建立新的映射 if (sessionHash) { - await this._setSessionMapping(sessionHash, selectedAccount.accountId, selectedAccount.accountType); - logger.info(`🎯 Created new sticky session mapping in group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`); + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping in group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` + ) } - logger.info(`🎯 Selected account from Gemini group ${group.name}: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority}`); - + logger.info( + `🎯 Selected account from Gemini group ${group.name}: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority}` + ) + return { accountId: selectedAccount.accountId, accountType: selectedAccount.accountType - }; + } } catch (error) { - logger.error(`❌ Failed to select account from Gemini group ${groupId}:`, error); - throw error; + logger.error(`❌ Failed to select account from Gemini group ${groupId}:`, error) + throw error } } } -module.exports = new UnifiedGeminiScheduler(); \ No newline at end of file +module.exports = new UnifiedGeminiScheduler() diff --git a/src/utils/costCalculator.js b/src/utils/costCalculator.js index 656b9997..37462cbb 100644 --- a/src/utils/costCalculator.js +++ b/src/utils/costCalculator.js @@ -1,64 +1,63 @@ -const pricingService = require('../services/pricingService'); +const pricingService = require('../services/pricingService') // Claude模型价格配置 (USD per 1M tokens) - 备用定价 const MODEL_PRICING = { // Claude 3.5 Sonnet 'claude-3-5-sonnet-20241022': { - input: 3.00, - output: 15.00, + input: 3.0, + output: 15.0, cacheWrite: 3.75, - cacheRead: 0.30 + cacheRead: 0.3 }, 'claude-sonnet-4-20250514': { - input: 3.00, - output: 15.00, + input: 3.0, + output: 15.0, cacheWrite: 3.75, - cacheRead: 0.30 + cacheRead: 0.3 }, - + // Claude 3.5 Haiku 'claude-3-5-haiku-20241022': { input: 0.25, output: 1.25, - cacheWrite: 0.30, + cacheWrite: 0.3, cacheRead: 0.03 }, - + // Claude 3 Opus 'claude-3-opus-20240229': { - input: 15.00, - output: 75.00, + input: 15.0, + output: 75.0, cacheWrite: 18.75, - cacheRead: 1.50 + cacheRead: 1.5 }, - + // Claude 3 Sonnet 'claude-3-sonnet-20240229': { - input: 3.00, - output: 15.00, + input: 3.0, + output: 15.0, cacheWrite: 3.75, - cacheRead: 0.30 + cacheRead: 0.3 }, - + // Claude 3 Haiku 'claude-3-haiku-20240307': { input: 0.25, output: 1.25, - cacheWrite: 0.30, + cacheWrite: 0.3, cacheRead: 0.03 }, - + // 默认定价(用于未知模型) - 'unknown': { - input: 3.00, - output: 15.00, + unknown: { + input: 3.0, + output: 15.0, cacheWrite: 3.75, - cacheRead: 0.30 + cacheRead: 0.3 } -}; +} class CostCalculator { - /** * 计算单次请求的费用 * @param {Object} usage - 使用量数据 @@ -70,16 +69,16 @@ class CostCalculator { * @returns {Object} 费用详情 */ static calculateCost(usage, model = 'unknown') { - const inputTokens = usage.input_tokens || 0; - const outputTokens = usage.output_tokens || 0; - const cacheCreateTokens = usage.cache_creation_input_tokens || 0; - const cacheReadTokens = usage.cache_read_input_tokens || 0; - + const inputTokens = usage.input_tokens || 0 + const outputTokens = usage.output_tokens || 0 + const cacheCreateTokens = usage.cache_creation_input_tokens || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + // 优先使用动态价格服务 - const pricingData = pricingService.getModelPricing(model); - let pricing; - let usingDynamicPricing = false; - + const pricingData = pricingService.getModelPricing(model) + let pricing + let usingDynamicPricing = false + if (pricingData) { // 转换动态价格格式为内部格式 pricing = { @@ -87,21 +86,21 @@ class CostCalculator { output: (pricingData.output_cost_per_token || 0) * 1000000, cacheWrite: (pricingData.cache_creation_input_token_cost || 0) * 1000000, cacheRead: (pricingData.cache_read_input_token_cost || 0) * 1000000 - }; - usingDynamicPricing = true; + } + usingDynamicPricing = true } else { // 回退到静态价格 - pricing = MODEL_PRICING[model] || MODEL_PRICING['unknown']; + pricing = MODEL_PRICING[model] || MODEL_PRICING['unknown'] } - + // 计算各类型token的费用 (USD) - const inputCost = (inputTokens / 1000000) * pricing.input; - const outputCost = (outputTokens / 1000000) * pricing.output; - const cacheWriteCost = (cacheCreateTokens / 1000000) * pricing.cacheWrite; - const cacheReadCost = (cacheReadTokens / 1000000) * pricing.cacheRead; - - const totalCost = inputCost + outputCost + cacheWriteCost + cacheReadCost; - + const inputCost = (inputTokens / 1000000) * pricing.input + const outputCost = (outputTokens / 1000000) * pricing.output + const cacheWriteCost = (cacheCreateTokens / 1000000) * pricing.cacheWrite + const cacheReadCost = (cacheReadTokens / 1000000) * pricing.cacheRead + + const totalCost = inputCost + outputCost + cacheWriteCost + cacheReadCost + return { model, pricing, @@ -128,9 +127,9 @@ class CostCalculator { cacheRead: this.formatCost(cacheReadCost), total: this.formatCost(totalCost) } - }; + } } - + /** * 计算聚合使用量的费用 * @param {Object} aggregatedUsage - 聚合使用量数据 @@ -141,39 +140,41 @@ class CostCalculator { const usage = { input_tokens: aggregatedUsage.inputTokens || aggregatedUsage.totalInputTokens || 0, output_tokens: aggregatedUsage.outputTokens || aggregatedUsage.totalOutputTokens || 0, - cache_creation_input_tokens: aggregatedUsage.cacheCreateTokens || aggregatedUsage.totalCacheCreateTokens || 0, - cache_read_input_tokens: aggregatedUsage.cacheReadTokens || aggregatedUsage.totalCacheReadTokens || 0 - }; - - return this.calculateCost(usage, model); + cache_creation_input_tokens: + aggregatedUsage.cacheCreateTokens || aggregatedUsage.totalCacheCreateTokens || 0, + cache_read_input_tokens: + aggregatedUsage.cacheReadTokens || aggregatedUsage.totalCacheReadTokens || 0 + } + + return this.calculateCost(usage, model) } - + /** * 获取模型定价信息 * @param {string} model - 模型名称 * @returns {Object} 定价信息 */ static getModelPricing(model = 'unknown') { - return MODEL_PRICING[model] || MODEL_PRICING['unknown']; + return MODEL_PRICING[model] || MODEL_PRICING['unknown'] } - + /** * 获取所有支持的模型和定价 * @returns {Object} 所有模型定价 */ static getAllModelPricing() { - return { ...MODEL_PRICING }; + return { ...MODEL_PRICING } } - + /** * 验证模型是否支持 * @param {string} model - 模型名称 * @returns {boolean} 是否支持 */ static isModelSupported(model) { - return !!MODEL_PRICING[model]; + return !!MODEL_PRICING[model] } - + /** * 格式化费用显示 * @param {number} cost - 费用金额 @@ -182,14 +183,14 @@ class CostCalculator { */ static formatCost(cost, decimals = 6) { if (cost >= 1) { - return `$${cost.toFixed(2)}`; + return `$${cost.toFixed(2)}` } else if (cost >= 0.001) { - return `$${cost.toFixed(4)}`; + return `$${cost.toFixed(4)}` } else { - return `$${cost.toFixed(decimals)}`; + return `$${cost.toFixed(decimals)}` } } - + /** * 计算费用节省(使用缓存的节省) * @param {Object} usage - 使用量数据 @@ -197,15 +198,15 @@ class CostCalculator { * @returns {Object} 节省信息 */ static calculateCacheSavings(usage, model = 'unknown') { - const pricing = this.getModelPricing(model); - const cacheReadTokens = usage.cache_read_input_tokens || 0; - + const pricing = this.getModelPricing(model) + const cacheReadTokens = usage.cache_read_input_tokens || 0 + // 如果这些token不使用缓存,需要按正常input价格计费 - const normalCost = (cacheReadTokens / 1000000) * pricing.input; - const cacheCost = (cacheReadTokens / 1000000) * pricing.cacheRead; - const savings = normalCost - cacheCost; - const savingsPercentage = normalCost > 0 ? (savings / normalCost) * 100 : 0; - + const normalCost = (cacheReadTokens / 1000000) * pricing.input + const cacheCost = (cacheReadTokens / 1000000) * pricing.cacheRead + const savings = normalCost - cacheCost + const savingsPercentage = normalCost > 0 ? (savings / normalCost) * 100 : 0 + return { normalCost, cacheCost, @@ -217,8 +218,8 @@ class CostCalculator { savings: this.formatCost(savings), savingsPercentage: `${savingsPercentage.toFixed(1)}%` } - }; + } } } -module.exports = CostCalculator; \ No newline at end of file +module.exports = CostCalculator diff --git a/src/utils/logger.js b/src/utils/logger.js index 87765dad..29045620 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -1,52 +1,58 @@ -const winston = require('winston'); -const DailyRotateFile = require('winston-daily-rotate-file'); -const config = require('../../config/config'); -const path = require('path'); -const fs = require('fs'); -const os = require('os'); +const winston = require('winston') +const DailyRotateFile = require('winston-daily-rotate-file') +const config = require('../../config/config') +const path = require('path') +const fs = require('fs') +const os = require('os') // 安全的 JSON 序列化函数,处理循环引用 const safeStringify = (obj, maxDepth = 3) => { - const seen = new WeakSet(); - + const seen = new WeakSet() + const replacer = (key, value, depth = 0) => { - if (depth > maxDepth) return '[Max Depth Reached]'; - + if (depth > maxDepth) { + return '[Max Depth Reached]' + } + if (value !== null && typeof value === 'object') { if (seen.has(value)) { - return '[Circular Reference]'; + return '[Circular Reference]' } - seen.add(value); - + seen.add(value) + // 过滤掉常见的循环引用对象 if (value.constructor) { - const constructorName = value.constructor.name; - if (['Socket', 'TLSSocket', 'HTTPParser', 'IncomingMessage', 'ServerResponse'].includes(constructorName)) { - return `[${constructorName} Object]`; + const constructorName = value.constructor.name + if ( + ['Socket', 'TLSSocket', 'HTTPParser', 'IncomingMessage', 'ServerResponse'].includes( + constructorName + ) + ) { + return `[${constructorName} Object]` } } - + // 递归处理对象属性 if (Array.isArray(value)) { - return value.map((item, index) => replacer(index, item, depth + 1)); + return value.map((item, index) => replacer(index, item, depth + 1)) } else { - const result = {}; + const result = {} for (const [k, v] of Object.entries(value)) { - result[k] = replacer(k, v, depth + 1); + result[k] = replacer(k, v, depth + 1) } - return result; + return result } } - - return value; - }; - - try { - return JSON.stringify(replacer('', obj)); - } catch (error) { - return JSON.stringify({ error: 'Failed to serialize object', message: error.message }); + + return value } -}; + + try { + return JSON.stringify(replacer('', obj)) + } catch (error) { + return JSON.stringify({ error: 'Failed to serialize object', message: error.message }) + } +} // 📝 增强的日志格式 const createLogFormat = (colorize = false) => { @@ -54,12 +60,12 @@ const createLogFormat = (colorize = false) => { winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.errors({ stack: true }), winston.format.metadata({ fillExcept: ['message', 'level', 'timestamp', 'stack'] }) - ]; - + ] + if (colorize) { - formats.push(winston.format.colorize()); + formats.push(winston.format.colorize()) } - + formats.push( winston.format.printf(({ level, message, timestamp, stack, metadata, ...rest }) => { const emoji = { @@ -68,39 +74,39 @@ const createLogFormat = (colorize = false) => { info: 'ℹ️ ', debug: '🐛', verbose: '📝' - }; - - let logMessage = `${emoji[level] || '📝'} [${timestamp}] ${level.toUpperCase()}: ${message}`; - + } + + let logMessage = `${emoji[level] || '📝'} [${timestamp}] ${level.toUpperCase()}: ${message}` + // 添加元数据 if (metadata && Object.keys(metadata).length > 0) { - logMessage += ` | ${safeStringify(metadata)}`; + logMessage += ` | ${safeStringify(metadata)}` } - - // 添加其他属性 - const additionalData = { ...rest }; - delete additionalData.level; - delete additionalData.message; - delete additionalData.timestamp; - delete additionalData.stack; - - if (Object.keys(additionalData).length > 0) { - logMessage += ` | ${safeStringify(additionalData)}`; - } - - return stack ? `${logMessage}\n${stack}` : logMessage; - }) - ); - - return winston.format.combine(...formats); -}; -const logFormat = createLogFormat(false); -const consoleFormat = createLogFormat(true); + // 添加其他属性 + const additionalData = { ...rest } + delete additionalData.level + delete additionalData.message + delete additionalData.timestamp + delete additionalData.stack + + if (Object.keys(additionalData).length > 0) { + logMessage += ` | ${safeStringify(additionalData)}` + } + + return stack ? `${logMessage}\n${stack}` : logMessage + }) + ) + + return winston.format.combine(...formats) +} + +const logFormat = createLogFormat(false) +const consoleFormat = createLogFormat(true) // 📁 确保日志目录存在并设置权限 if (!fs.existsSync(config.logging.dirname)) { - fs.mkdirSync(config.logging.dirname, { recursive: true, mode: 0o755 }); + fs.mkdirSync(config.logging.dirname, { recursive: true, mode: 0o755 }) } // 🔄 增强的日志轮转配置 @@ -113,40 +119,38 @@ const createRotateTransport = (filename, level = null) => { maxFiles: config.logging.maxFiles, auditFile: path.join(config.logging.dirname, `.${filename.replace('%DATE%', 'audit')}.json`), format: logFormat - }); - + }) + if (level) { - transport.level = level; + transport.level = level } - + // 监听轮转事件 transport.on('rotate', (oldFilename, newFilename) => { - console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`); - }); - - transport.on('new', (newFilename) => { - console.log(`📄 New log file created: ${newFilename}`); - }); - - transport.on('archive', (zipFilename) => { - console.log(`🗜️ Log archived: ${zipFilename}`); - }); - - return transport; -}; + console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`) + }) -const dailyRotateFileTransport = createRotateTransport('claude-relay-%DATE%.log'); -const errorFileTransport = createRotateTransport('claude-relay-error-%DATE%.log', 'error'); + transport.on('new', (newFilename) => { + console.log(`📄 New log file created: ${newFilename}`) + }) + + transport.on('archive', (zipFilename) => { + console.log(`🗜️ Log archived: ${zipFilename}`) + }) + + return transport +} + +const dailyRotateFileTransport = createRotateTransport('claude-relay-%DATE%.log') +const errorFileTransport = createRotateTransport('claude-relay-error-%DATE%.log', 'error') // 🔒 创建专门的安全日志记录器 const securityLogger = winston.createLogger({ level: 'warn', format: logFormat, - transports: [ - createRotateTransport('claude-relay-security-%DATE%.log', 'warn') - ], + transports: [createRotateTransport('claude-relay-security-%DATE%.log', 'warn')], silent: false -}); +}) // 🌟 增强的 Winston logger const logger = winston.createLogger({ @@ -156,7 +160,7 @@ const logger = winston.createLogger({ // 📄 文件输出 dailyRotateFileTransport, errorFileTransport, - + // 🖥️ 控制台输出 new winston.transports.Console({ format: consoleFormat, @@ -164,10 +168,10 @@ const logger = winston.createLogger({ handleRejections: false }) ], - + // 🚨 异常处理 exceptionHandlers: [ - new winston.transports.File({ + new winston.transports.File({ filename: path.join(config.logging.dirname, 'exceptions.log'), format: logFormat, maxsize: 10485760, // 10MB @@ -177,10 +181,10 @@ const logger = winston.createLogger({ format: consoleFormat }) ], - + // 🔄 未捕获异常处理 rejectionHandlers: [ - new winston.transports.File({ + new winston.transports.File({ filename: path.join(config.logging.dirname, 'rejections.log'), format: logFormat, maxsize: 10485760, // 10MB @@ -190,24 +194,24 @@ const logger = winston.createLogger({ format: consoleFormat }) ], - + // 防止进程退出 exitOnError: false -}); +}) // 🎯 增强的自定义方法 logger.success = (message, metadata = {}) => { - logger.info(`✅ ${message}`, { type: 'success', ...metadata }); -}; + logger.info(`✅ ${message}`, { type: 'success', ...metadata }) +} logger.start = (message, metadata = {}) => { - logger.info(`🚀 ${message}`, { type: 'startup', ...metadata }); -}; + logger.info(`🚀 ${message}`, { type: 'startup', ...metadata }) +} logger.request = (method, url, status, duration, metadata = {}) => { - const emoji = status >= 400 ? '🔴' : status >= 300 ? '🟡' : '🟢'; - const level = status >= 400 ? 'error' : status >= 300 ? 'warn' : 'info'; - + const emoji = status >= 400 ? '🔴' : status >= 300 ? '🟡' : '🟢' + const level = status >= 400 ? 'error' : status >= 300 ? 'warn' : 'info' + logger[level](`${emoji} ${method} ${url} - ${status} (${duration}ms)`, { type: 'request', method, @@ -215,12 +219,12 @@ logger.request = (method, url, status, duration, metadata = {}) => { status, duration, ...metadata - }); -}; + }) +} logger.api = (message, metadata = {}) => { - logger.info(`🔗 ${message}`, { type: 'api', ...metadata }); -}; + logger.info(`🔗 ${message}`, { type: 'api', ...metadata }) +} logger.security = (message, metadata = {}) => { const securityData = { @@ -229,99 +233,99 @@ logger.security = (message, metadata = {}) => { pid: process.pid, hostname: os.hostname(), ...metadata - }; - + } + // 记录到主日志 - logger.warn(`🔒 ${message}`, securityData); - + logger.warn(`🔒 ${message}`, securityData) + // 记录到专门的安全日志文件 try { - securityLogger.warn(`🔒 ${message}`, securityData); + securityLogger.warn(`🔒 ${message}`, securityData) } catch (error) { // 如果安全日志文件不可用,只记录到主日志 - console.warn('Security logger not available:', error.message); + console.warn('Security logger not available:', error.message) } -}; +} logger.database = (message, metadata = {}) => { - logger.debug(`💾 ${message}`, { type: 'database', ...metadata }); -}; + logger.debug(`💾 ${message}`, { type: 'database', ...metadata }) +} logger.performance = (message, metadata = {}) => { - logger.info(`⚡ ${message}`, { type: 'performance', ...metadata }); -}; + logger.info(`⚡ ${message}`, { type: 'performance', ...metadata }) +} logger.audit = (message, metadata = {}) => { - logger.info(`📋 ${message}`, { + logger.info(`📋 ${message}`, { type: 'audit', timestamp: new Date().toISOString(), pid: process.pid, - ...metadata - }); -}; + ...metadata + }) +} // 🔧 性能监控方法 logger.timer = (label) => { - const start = Date.now(); + const start = Date.now() return { end: (message = '', metadata = {}) => { - const duration = Date.now() - start; - logger.performance(`${label} ${message}`, { duration, ...metadata }); - return duration; + const duration = Date.now() - start + logger.performance(`${label} ${message}`, { duration, ...metadata }) + return duration } - }; -}; + } +} // 📊 日志统计 logger.stats = { requests: 0, errors: 0, warnings: 0 -}; +} // 重写原始方法以统计 -const originalError = logger.error; -const originalWarn = logger.warn; -const originalInfo = logger.info; +const originalError = logger.error +const originalWarn = logger.warn +const originalInfo = logger.info -logger.error = function(message, ...args) { - logger.stats.errors++; - return originalError.call(this, message, ...args); -}; +logger.error = function (message, ...args) { + logger.stats.errors++ + return originalError.call(this, message, ...args) +} -logger.warn = function(message, ...args) { - logger.stats.warnings++; - return originalWarn.call(this, message, ...args); -}; +logger.warn = function (message, ...args) { + logger.stats.warnings++ + return originalWarn.call(this, message, ...args) +} -logger.info = function(message, ...args) { +logger.info = function (message, ...args) { // 检查是否是请求类型的日志 if (args.length > 0 && typeof args[0] === 'object' && args[0].type === 'request') { - logger.stats.requests++; + logger.stats.requests++ } - return originalInfo.call(this, message, ...args); -}; + return originalInfo.call(this, message, ...args) +} // 📈 获取日志统计 -logger.getStats = () => ({ ...logger.stats }); +logger.getStats = () => ({ ...logger.stats }) // 🧹 清理统计 logger.resetStats = () => { - logger.stats.requests = 0; - logger.stats.errors = 0; - logger.stats.warnings = 0; -}; + logger.stats.requests = 0 + logger.stats.errors = 0 + logger.stats.warnings = 0 +} // 📡 健康检查 logger.healthCheck = () => { try { - const testMessage = 'Logger health check'; - logger.debug(testMessage); - return { healthy: true, timestamp: new Date().toISOString() }; + const testMessage = 'Logger health check' + logger.debug(testMessage) + return { healthy: true, timestamp: new Date().toISOString() } } catch (error) { - return { healthy: false, error: error.message, timestamp: new Date().toISOString() }; + return { healthy: false, error: error.message, timestamp: new Date().toISOString() } } -}; +} // 🎬 启动日志记录系统 logger.start('Logger initialized', { @@ -330,6 +334,6 @@ logger.start('Logger initialized', { maxSize: config.logging.maxSize, maxFiles: config.logging.maxFiles, envOverride: process.env.LOG_LEVEL ? true : false -}); +}) -module.exports = logger; \ No newline at end of file +module.exports = logger diff --git a/src/utils/oauthHelper.js b/src/utils/oauthHelper.js index 5389dd86..036c15fd 100644 --- a/src/utils/oauthHelper.js +++ b/src/utils/oauthHelper.js @@ -3,27 +3,27 @@ * 基于claude-code-login.js中的OAuth流程实现 */ -const crypto = require('crypto'); -const { SocksProxyAgent } = require('socks-proxy-agent'); -const { HttpsProxyAgent } = require('https-proxy-agent'); -const axios = require('axios'); -const logger = require('./logger'); +const crypto = require('crypto') +const { SocksProxyAgent } = require('socks-proxy-agent') +const { HttpsProxyAgent } = require('https-proxy-agent') +const axios = require('axios') +const logger = require('./logger') // OAuth 配置常量 - 从claude-code-login.js提取 const OAUTH_CONFIG = { - AUTHORIZE_URL: 'https://claude.ai/oauth/authorize', - TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token', - CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e', - REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback', - SCOPES: 'org:create_api_key user:profile user:inference' -}; + AUTHORIZE_URL: 'https://claude.ai/oauth/authorize', + TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token', + CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e', + REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback', + SCOPES: 'org:create_api_key user:profile user:inference' +} /** * 生成随机的 state 参数 * @returns {string} 随机生成的 state (64字符hex) */ function generateState() { - return crypto.randomBytes(32).toString('hex'); + return crypto.randomBytes(32).toString('hex') } /** @@ -31,7 +31,7 @@ function generateState() { * @returns {string} base64url 编码的随机字符串 */ function generateCodeVerifier() { - return crypto.randomBytes(32).toString('base64url'); + return crypto.randomBytes(32).toString('base64url') } /** @@ -40,9 +40,7 @@ function generateCodeVerifier() { * @returns {string} SHA256 哈希后的 base64url 编码字符串 */ function generateCodeChallenge(codeVerifier) { - return crypto.createHash('sha256') - .update(codeVerifier) - .digest('base64url'); + return crypto.createHash('sha256').update(codeVerifier).digest('base64url') } /** @@ -52,18 +50,18 @@ function generateCodeChallenge(codeVerifier) { * @returns {string} 完整的授权 URL */ function generateAuthUrl(codeChallenge, state) { - const params = new URLSearchParams({ - code: 'true', - client_id: OAUTH_CONFIG.CLIENT_ID, - response_type: 'code', - redirect_uri: OAUTH_CONFIG.REDIRECT_URI, - scope: OAUTH_CONFIG.SCOPES, - code_challenge: codeChallenge, - code_challenge_method: 'S256', - state: state - }); + const params = new URLSearchParams({ + code: 'true', + client_id: OAUTH_CONFIG.CLIENT_ID, + response_type: 'code', + redirect_uri: OAUTH_CONFIG.REDIRECT_URI, + scope: OAUTH_CONFIG.SCOPES, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state + }) - return `${OAUTH_CONFIG.AUTHORIZE_URL}?${params.toString()}`; + return `${OAUTH_CONFIG.AUTHORIZE_URL}?${params.toString()}` } /** @@ -71,18 +69,18 @@ function generateAuthUrl(codeChallenge, state) { * @returns {{authUrl: string, codeVerifier: string, state: string, codeChallenge: string}} */ function generateOAuthParams() { - const state = generateState(); - const codeVerifier = generateCodeVerifier(); - const codeChallenge = generateCodeChallenge(codeVerifier); - - const authUrl = generateAuthUrl(codeChallenge, state); - - return { - authUrl, - codeVerifier, - state, - codeChallenge - }; + const state = generateState() + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + + const authUrl = generateAuthUrl(codeChallenge, state) + + return { + authUrl, + codeVerifier, + state, + codeChallenge + } } /** @@ -91,25 +89,31 @@ function generateOAuthParams() { * @returns {object|null} 代理agent或null */ function createProxyAgent(proxyConfig) { - if (!proxyConfig) { - return null; - } + if (!proxyConfig) { + return null + } - try { - if (proxyConfig.type === 'socks5') { - const auth = proxyConfig.username && proxyConfig.password ? `${proxyConfig.username}:${proxyConfig.password}@` : ''; - const socksUrl = `socks5://${auth}${proxyConfig.host}:${proxyConfig.port}`; - return new SocksProxyAgent(socksUrl); - } else if (proxyConfig.type === 'http' || proxyConfig.type === 'https') { - const auth = proxyConfig.username && proxyConfig.password ? `${proxyConfig.username}:${proxyConfig.password}@` : ''; - const httpUrl = `${proxyConfig.type}://${auth}${proxyConfig.host}:${proxyConfig.port}`; - return new HttpsProxyAgent(httpUrl); - } - } catch (error) { - console.warn('⚠️ Invalid proxy configuration:', error); + try { + if (proxyConfig.type === 'socks5') { + const auth = + proxyConfig.username && proxyConfig.password + ? `${proxyConfig.username}:${proxyConfig.password}@` + : '' + const socksUrl = `socks5://${auth}${proxyConfig.host}:${proxyConfig.port}` + return new SocksProxyAgent(socksUrl) + } else if (proxyConfig.type === 'http' || proxyConfig.type === 'https') { + const auth = + proxyConfig.username && proxyConfig.password + ? `${proxyConfig.username}:${proxyConfig.password}@` + : '' + const httpUrl = `${proxyConfig.type}://${auth}${proxyConfig.host}:${proxyConfig.port}` + return new HttpsProxyAgent(httpUrl) } + } catch (error) { + console.warn('⚠️ Invalid proxy configuration:', error) + } - return null; + return null } /** @@ -121,110 +125,110 @@ function createProxyAgent(proxyConfig) { * @returns {Promise} Claude格式的token响应 */ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, proxyConfig = null) { - // 清理授权码,移除URL片段 - const cleanedCode = authorizationCode.split('#')[0]?.split('&')[0] ?? authorizationCode; - - const params = { - grant_type: 'authorization_code', - client_id: OAUTH_CONFIG.CLIENT_ID, - code: cleanedCode, - redirect_uri: OAUTH_CONFIG.REDIRECT_URI, - code_verifier: codeVerifier, - state: state - }; + // 清理授权码,移除URL片段 + const cleanedCode = authorizationCode.split('#')[0]?.split('&')[0] ?? authorizationCode - // 创建代理agent - const agent = createProxyAgent(proxyConfig); + const params = { + grant_type: 'authorization_code', + client_id: OAUTH_CONFIG.CLIENT_ID, + code: cleanedCode, + redirect_uri: OAUTH_CONFIG.REDIRECT_URI, + code_verifier: codeVerifier, + state + } - try { - logger.debug('🔄 Attempting OAuth token exchange', { - url: OAUTH_CONFIG.TOKEN_URL, - codeLength: cleanedCode.length, - codePrefix: cleanedCode.substring(0, 10) + '...', - hasProxy: !!proxyConfig, - proxyType: proxyConfig?.type || 'none' - }); + // 创建代理agent + const agent = createProxyAgent(proxyConfig) - const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, { - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'claude-cli/1.0.56 (external, cli)', - 'Accept': 'application/json, text/plain, */*', - 'Accept-Language': 'en-US,en;q=0.9', - 'Referer': 'https://claude.ai/', - 'Origin': 'https://claude.ai' - }, - httpsAgent: agent, - timeout: 30000 - }); + try { + logger.debug('🔄 Attempting OAuth token exchange', { + url: OAUTH_CONFIG.TOKEN_URL, + codeLength: cleanedCode.length, + codePrefix: `${cleanedCode.substring(0, 10)}...`, + hasProxy: !!proxyConfig, + proxyType: proxyConfig?.type || 'none' + }) - logger.success('✅ OAuth token exchange successful', { - status: response.status, - hasAccessToken: !!response.data?.access_token, - hasRefreshToken: !!response.data?.refresh_token, - scopes: response.data?.scope - }); + const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'claude-cli/1.0.56 (external, cli)', + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', + Referer: 'https://claude.ai/', + Origin: 'https://claude.ai' + }, + httpsAgent: agent, + timeout: 30000 + }) - const data = response.data; - - // 返回Claude格式的token数据 - return { - accessToken: data.access_token, - refreshToken: data.refresh_token, - expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000, - scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'], - isMax: true - }; - } catch (error) { - // 处理axios错误响应 - if (error.response) { - // 服务器返回了错误状态码 - const status = error.response.status; - const errorData = error.response.data; - - logger.error('❌ OAuth token exchange failed with server error', { - status: status, - statusText: error.response.statusText, - headers: error.response.headers, - data: errorData, - codeLength: cleanedCode.length, - codePrefix: cleanedCode.substring(0, 10) + '...' - }); - - // 尝试从错误响应中提取有用信息 - let errorMessage = `HTTP ${status}`; - - if (errorData) { - if (typeof errorData === 'string') { - errorMessage += `: ${errorData}`; - } else if (errorData.error) { - errorMessage += `: ${errorData.error}`; - if (errorData.error_description) { - errorMessage += ` - ${errorData.error_description}`; - } - } else { - errorMessage += `: ${JSON.stringify(errorData)}`; - } - } - - throw new Error(`Token exchange failed: ${errorMessage}`); - } else if (error.request) { - // 请求被发送但没有收到响应 - logger.error('❌ OAuth token exchange failed with network error', { - message: error.message, - code: error.code, - hasProxy: !!proxyConfig - }); - throw new Error('Token exchange failed: No response from server (network error or timeout)'); - } else { - // 其他错误 - logger.error('❌ OAuth token exchange failed with unknown error', { - message: error.message, - stack: error.stack - }); - throw new Error(`Token exchange failed: ${error.message}`); - } + logger.success('✅ OAuth token exchange successful', { + status: response.status, + hasAccessToken: !!response.data?.access_token, + hasRefreshToken: !!response.data?.refresh_token, + scopes: response.data?.scope + }) + + const { data } = response + + // 返回Claude格式的token数据 + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000, + scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'], + isMax: true } + } catch (error) { + // 处理axios错误响应 + if (error.response) { + // 服务器返回了错误状态码 + const { status } = error.response + const errorData = error.response.data + + logger.error('❌ OAuth token exchange failed with server error', { + status, + statusText: error.response.statusText, + headers: error.response.headers, + data: errorData, + codeLength: cleanedCode.length, + codePrefix: `${cleanedCode.substring(0, 10)}...` + }) + + // 尝试从错误响应中提取有用信息 + let errorMessage = `HTTP ${status}` + + if (errorData) { + if (typeof errorData === 'string') { + errorMessage += `: ${errorData}` + } else if (errorData.error) { + errorMessage += `: ${errorData.error}` + if (errorData.error_description) { + errorMessage += ` - ${errorData.error_description}` + } + } else { + errorMessage += `: ${JSON.stringify(errorData)}` + } + } + + throw new Error(`Token exchange failed: ${errorMessage}`) + } else if (error.request) { + // 请求被发送但没有收到响应 + logger.error('❌ OAuth token exchange failed with network error', { + message: error.message, + code: error.code, + hasProxy: !!proxyConfig + }) + throw new Error('Token exchange failed: No response from server (network error or timeout)') + } else { + // 其他错误 + logger.error('❌ OAuth token exchange failed with unknown error', { + message: error.message, + stack: error.stack + }) + throw new Error(`Token exchange failed: ${error.message}`) + } + } } /** @@ -233,47 +237,47 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro * @returns {string} 授权码 */ function parseCallbackUrl(input) { - if (!input || typeof input !== 'string') { - throw new Error('请提供有效的授权码或回调 URL'); - } + if (!input || typeof input !== 'string') { + throw new Error('请提供有效的授权码或回调 URL') + } - const trimmedInput = input.trim(); - - // 情况1: 尝试作为完整URL解析 - if (trimmedInput.startsWith('http://') || trimmedInput.startsWith('https://')) { - try { - const urlObj = new URL(trimmedInput); - const authorizationCode = urlObj.searchParams.get('code'); + const trimmedInput = input.trim() - if (!authorizationCode) { - throw new Error('回调 URL 中未找到授权码 (code 参数)'); - } + // 情况1: 尝试作为完整URL解析 + if (trimmedInput.startsWith('http://') || trimmedInput.startsWith('https://')) { + try { + const urlObj = new URL(trimmedInput) + const authorizationCode = urlObj.searchParams.get('code') - return authorizationCode; - } catch (error) { - if (error.message.includes('回调 URL 中未找到授权码')) { - throw error; - } - throw new Error('无效的 URL 格式,请检查回调 URL 是否正确'); - } + if (!authorizationCode) { + throw new Error('回调 URL 中未找到授权码 (code 参数)') + } + + return authorizationCode + } catch (error) { + if (error.message.includes('回调 URL 中未找到授权码')) { + throw error + } + throw new Error('无效的 URL 格式,请检查回调 URL 是否正确') } - - // 情况2: 直接的授权码(可能包含URL fragments) - // 参考claude-code-login.js的处理方式:移除URL fragments和参数 - const cleanedCode = trimmedInput.split('#')[0]?.split('&')[0] ?? trimmedInput; - - // 验证授权码格式(Claude的授权码通常是base64url格式) - if (!cleanedCode || cleanedCode.length < 10) { - throw new Error('授权码格式无效,请确保复制了完整的 Authorization Code'); - } - - // 基本格式验证:授权码应该只包含字母、数字、下划线、连字符 - const validCodePattern = /^[A-Za-z0-9_-]+$/; - if (!validCodePattern.test(cleanedCode)) { - throw new Error('授权码包含无效字符,请检查是否复制了正确的 Authorization Code'); - } - - return cleanedCode; + } + + // 情况2: 直接的授权码(可能包含URL fragments) + // 参考claude-code-login.js的处理方式:移除URL fragments和参数 + const cleanedCode = trimmedInput.split('#')[0]?.split('&')[0] ?? trimmedInput + + // 验证授权码格式(Claude的授权码通常是base64url格式) + if (!cleanedCode || cleanedCode.length < 10) { + throw new Error('授权码格式无效,请确保复制了完整的 Authorization Code') + } + + // 基本格式验证:授权码应该只包含字母、数字、下划线、连字符 + const validCodePattern = /^[A-Za-z0-9_-]+$/ + if (!validCodePattern.test(cleanedCode)) { + throw new Error('授权码包含无效字符,请检查是否复制了正确的 Authorization Code') + } + + return cleanedCode } /** @@ -282,26 +286,26 @@ function parseCallbackUrl(input) { * @returns {object} claudeAiOauth格式的数据 */ function formatClaudeCredentials(tokenData) { - return { - claudeAiOauth: { - accessToken: tokenData.accessToken, - refreshToken: tokenData.refreshToken, - expiresAt: tokenData.expiresAt, - scopes: tokenData.scopes, - isMax: tokenData.isMax - } - }; + return { + claudeAiOauth: { + accessToken: tokenData.accessToken, + refreshToken: tokenData.refreshToken, + expiresAt: tokenData.expiresAt, + scopes: tokenData.scopes, + isMax: tokenData.isMax + } + } } module.exports = { - OAUTH_CONFIG, - generateOAuthParams, - exchangeCodeForTokens, - parseCallbackUrl, - formatClaudeCredentials, - generateState, - generateCodeVerifier, - generateCodeChallenge, - generateAuthUrl, - createProxyAgent -}; \ No newline at end of file + OAUTH_CONFIG, + generateOAuthParams, + exchangeCodeForTokens, + parseCallbackUrl, + formatClaudeCredentials, + generateState, + generateCodeVerifier, + generateCodeChallenge, + generateAuthUrl, + createProxyAgent +} diff --git a/src/utils/sessionHelper.js b/src/utils/sessionHelper.js index f025c5c8..c8313eaf 100644 --- a/src/utils/sessionHelper.js +++ b/src/utils/sessionHelper.js @@ -1,5 +1,5 @@ -const crypto = require('crypto'); -const logger = require('./logger'); +const crypto = require('crypto') +const logger = require('./logger') class SessionHelper { /** @@ -10,92 +10,104 @@ class SessionHelper { */ generateSessionHash(requestBody) { if (!requestBody || typeof requestBody !== 'object') { - return null; + return null } - let cacheableContent = ''; - const system = requestBody.system || ''; - const messages = requestBody.messages || []; + let cacheableContent = '' + const system = requestBody.system || '' + const messages = requestBody.messages || [] // 1. 优先提取带有cache_control: {"type": "ephemeral"}的内容 // 检查system中的cacheable内容 if (Array.isArray(system)) { for (const part of system) { if (part && part.cache_control && part.cache_control.type === 'ephemeral') { - cacheableContent += part.text || ''; + cacheableContent += part.text || '' } } } // 检查messages中的cacheable内容 for (const msg of messages) { - const content = msg.content || ''; + const content = msg.content || '' if (Array.isArray(content)) { for (const part of content) { if (part && part.cache_control && part.cache_control.type === 'ephemeral') { if (part.type === 'text') { - cacheableContent += part.text || ''; + cacheableContent += part.text || '' } // 其他类型(如image)不参与hash计算 } } - } else if (typeof content === 'string' && msg.cache_control && msg.cache_control.type === 'ephemeral') { + } else if ( + typeof content === 'string' && + msg.cache_control && + msg.cache_control.type === 'ephemeral' + ) { // 罕见情况,但需要检查 - cacheableContent += content; + cacheableContent += content } } // 2. 如果有cacheable内容,直接使用 if (cacheableContent) { - const hash = crypto.createHash('sha256').update(cacheableContent).digest('hex').substring(0, 32); - logger.debug(`📋 Session hash generated from cacheable content: ${hash}`); - return hash; + const hash = crypto + .createHash('sha256') + .update(cacheableContent) + .digest('hex') + .substring(0, 32) + logger.debug(`📋 Session hash generated from cacheable content: ${hash}`) + return hash } // 3. Fallback: 使用system内容 if (system) { - let systemText = ''; + let systemText = '' if (typeof system === 'string') { - systemText = system; + systemText = system } else if (Array.isArray(system)) { - systemText = system.map(part => part.text || '').join(''); + systemText = system.map((part) => part.text || '').join('') } - + if (systemText) { - const hash = crypto.createHash('sha256').update(systemText).digest('hex').substring(0, 32); - logger.debug(`📋 Session hash generated from system content: ${hash}`); - return hash; + const hash = crypto.createHash('sha256').update(systemText).digest('hex').substring(0, 32) + logger.debug(`📋 Session hash generated from system content: ${hash}`) + return hash } } // 4. 最后fallback: 使用第一条消息内容 if (messages.length > 0) { - const firstMessage = messages[0]; - let firstMessageText = ''; - + const firstMessage = messages[0] + let firstMessageText = '' + if (typeof firstMessage.content === 'string') { - firstMessageText = firstMessage.content; + firstMessageText = firstMessage.content } else if (Array.isArray(firstMessage.content)) { if (!firstMessage.content) { - logger.error('📋 Session hash generated from first message failed: ', firstMessage); + logger.error('📋 Session hash generated from first message failed: ', firstMessage) } firstMessageText = firstMessage.content - .filter(part => part.type === 'text') - .map(part => part.text || '') - .join(''); + .filter((part) => part.type === 'text') + .map((part) => part.text || '') + .join('') } - + if (firstMessageText) { - const hash = crypto.createHash('sha256').update(firstMessageText).digest('hex').substring(0, 32); - logger.debug(`📋 Session hash generated from first message: ${hash}`); - return hash; + const hash = crypto + .createHash('sha256') + .update(firstMessageText) + .digest('hex') + .substring(0, 32) + logger.debug(`📋 Session hash generated from first message: ${hash}`) + return hash } } // 无法生成会话哈希 - logger.debug('📋 Unable to generate session hash - no suitable content found'); - return null; + logger.debug('📋 Unable to generate session hash - no suitable content found') + return null } /** @@ -104,7 +116,7 @@ class SessionHelper { * @returns {string} - Redis键名 */ getSessionRedisKey(sessionHash) { - return `sticky_session:${sessionHash}`; + return `sticky_session:${sessionHash}` } /** @@ -113,10 +125,12 @@ class SessionHelper { * @returns {boolean} - 是否有效 */ isValidSessionHash(sessionHash) { - return typeof sessionHash === 'string' && - sessionHash.length === 32 && - /^[a-f0-9]{32}$/.test(sessionHash); + return ( + typeof sessionHash === 'string' && + sessionHash.length === 32 && + /^[a-f0-9]{32}$/.test(sessionHash) + ) } } -module.exports = new SessionHelper(); +module.exports = new SessionHelper() diff --git a/src/utils/tokenMask.js b/src/utils/tokenMask.js index 81acc9d6..3ea94583 100644 --- a/src/utils/tokenMask.js +++ b/src/utils/tokenMask.js @@ -11,29 +11,29 @@ */ function maskToken(token, visiblePercent = 70) { if (!token || typeof token !== 'string') { - return '[EMPTY]'; + return '[EMPTY]' } - const length = token.length; - + const { length } = token + // 对于非常短的 token,至少隐藏一部分 if (length <= 10) { - return token.slice(0, 5) + '*'.repeat(length - 5); + return token.slice(0, 5) + '*'.repeat(length - 5) } // 计算可见字符数量 - const visibleLength = Math.floor(length * (visiblePercent / 100)); - + const visibleLength = Math.floor(length * (visiblePercent / 100)) + // 在前部和尾部分配可见字符 - const frontLength = Math.ceil(visibleLength * 0.6); - const backLength = visibleLength - frontLength; - + const frontLength = Math.ceil(visibleLength * 0.6) + const backLength = visibleLength - frontLength + // 构建脱敏后的 token - const front = token.slice(0, frontLength); - const back = token.slice(-backLength); - const middle = '*'.repeat(length - visibleLength); - - return `${front}${middle}${back}`; + const front = token.slice(0, frontLength) + const back = token.slice(-backLength) + const middle = '*'.repeat(length - visibleLength) + + return `${front}${middle}${back}` } /** @@ -42,20 +42,23 @@ function maskToken(token, visiblePercent = 70) { * @param {Array} tokenFields - 需要脱敏的字段名列表 * @returns {Object} 脱敏后的对象副本 */ -function maskTokensInObject(obj, tokenFields = ['accessToken', 'refreshToken', 'access_token', 'refresh_token']) { +function maskTokensInObject( + obj, + tokenFields = ['accessToken', 'refreshToken', 'access_token', 'refresh_token'] +) { if (!obj || typeof obj !== 'object') { - return obj; + return obj } - const masked = { ...obj }; - - tokenFields.forEach(field => { + const masked = { ...obj } + + tokenFields.forEach((field) => { if (masked[field]) { - masked[field] = maskToken(masked[field]); + masked[field] = maskToken(masked[field]) } - }); - - return masked; + }) + + return masked } /** @@ -75,21 +78,21 @@ function formatTokenRefreshLog(accountId, accountName, tokens, status, message = accountName, status, message - }; + } if (tokens) { log.tokens = { accessToken: tokens.accessToken ? maskToken(tokens.accessToken) : '[NOT_PROVIDED]', refreshToken: tokens.refreshToken ? maskToken(tokens.refreshToken) : '[NOT_PROVIDED]', expiresAt: tokens.expiresAt || '[NOT_PROVIDED]' - }; + } } - return log; + return log } module.exports = { maskToken, maskTokensInObject, formatTokenRefreshLog -}; \ No newline at end of file +} diff --git a/src/utils/tokenRefreshLogger.js b/src/utils/tokenRefreshLogger.js index ad86a090..f9031485 100644 --- a/src/utils/tokenRefreshLogger.js +++ b/src/utils/tokenRefreshLogger.js @@ -1,12 +1,12 @@ -const winston = require('winston'); -const path = require('path'); -const fs = require('fs'); -const { maskToken } = require('./tokenMask'); +const winston = require('winston') +const path = require('path') +const fs = require('fs') +const { maskToken } = require('./tokenMask') // 确保日志目录存在 -const logDir = path.join(process.cwd(), 'logs'); +const logDir = path.join(process.cwd(), 'logs') if (!fs.existsSync(logDir)) { - fs.mkdirSync(logDir, { recursive: true }); + fs.mkdirSync(logDir, { recursive: true }) } // 创建专用的 token 刷新日志记录器 @@ -17,9 +17,7 @@ const tokenRefreshLogger = winston.createLogger({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), winston.format.json(), - winston.format.printf(info => { - return JSON.stringify(info, null, 2); - }) + winston.format.printf((info) => JSON.stringify(info, null, 2)) ), transports: [ // 文件传输 - 每日轮转 @@ -39,16 +37,15 @@ const tokenRefreshLogger = winston.createLogger({ ], // 错误处理 exitOnError: false -}); +}) // 在开发环境添加控制台输出 if (process.env.NODE_ENV !== 'production') { - tokenRefreshLogger.add(new winston.transports.Console({ - format: winston.format.combine( - winston.format.colorize(), - winston.format.simple() - ) - })); + tokenRefreshLogger.add( + new winston.transports.Console({ + format: winston.format.combine(winston.format.colorize(), winston.format.simple()) + }) + ) } /** @@ -62,7 +59,7 @@ function logRefreshStart(accountId, accountName, platform = 'claude', reason = ' platform, reason, timestamp: new Date().toISOString() - }); + }) } /** @@ -74,7 +71,7 @@ function logRefreshSuccess(accountId, accountName, platform = 'claude', tokenDat refreshToken: tokenData.refreshToken ? maskToken(tokenData.refreshToken) : '[NOT_PROVIDED]', expiresAt: tokenData.expiresAt || tokenData.expiry_date || '[NOT_PROVIDED]', scopes: tokenData.scopes || tokenData.scope || '[NOT_PROVIDED]' - }; + } tokenRefreshLogger.info({ event: 'token_refresh_success', @@ -83,7 +80,7 @@ function logRefreshSuccess(accountId, accountName, platform = 'claude', tokenDat platform, tokenData: maskedTokenData, timestamp: new Date().toISOString() - }); + }) } /** @@ -95,7 +92,7 @@ function logRefreshError(accountId, accountName, platform = 'claude', error, att code: error.code || 'UNKNOWN', statusCode: error.response?.status || 'N/A', responseData: error.response?.data || 'N/A' - }; + } tokenRefreshLogger.error({ event: 'token_refresh_error', @@ -105,7 +102,7 @@ function logRefreshError(accountId, accountName, platform = 'claude', error, att error: errorInfo, attemptNumber, timestamp: new Date().toISOString() - }); + }) } /** @@ -119,7 +116,7 @@ function logRefreshSkipped(accountId, accountName, platform = 'claude', reason = platform, reason, timestamp: new Date().toISOString() - }); + }) } /** @@ -135,7 +132,7 @@ function logTokenUsage(accountId, accountName, platform = 'claude', expiresAt, i isExpired, remainingMinutes: expiresAt ? Math.floor((new Date(expiresAt) - Date.now()) / 60000) : 'N/A', timestamp: new Date().toISOString() - }); + }) } /** @@ -147,7 +144,7 @@ function logBatchRefreshStart(totalAccounts, platform = 'all') { totalAccounts, platform, timestamp: new Date().toISOString() - }); + }) } /** @@ -163,7 +160,7 @@ function logBatchRefreshComplete(results) { skipped: results.skipped || 0 }, timestamp: new Date().toISOString() - }); + }) } module.exports = { @@ -175,4 +172,4 @@ module.exports = { logTokenUsage, logBatchRefreshStart, logBatchRefreshComplete -}; \ No newline at end of file +} diff --git a/web/admin-spa/.eslintrc.cjs b/web/admin-spa/.eslintrc.cjs index 349b7420..2a625058 100644 --- a/web/admin-spa/.eslintrc.cjs +++ b/web/admin-spa/.eslintrc.cjs @@ -6,17 +6,39 @@ module.exports = { es2021: true }, extends: [ + 'plugin:vue/vue3-strongly-recommended', 'eslint:recommended', - 'plugin:vue/vue3-recommended' + 'plugin:prettier/recommended' ], parserOptions: { - ecmaVersion: 2021, - sourceType: 'module' + sourceType: 'module', + ecmaVersion: 'latest' }, + plugins: ['prettier'], rules: { 'vue/multi-word-component-names': 'off', 'vue/no-v-html': 'off', 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', - 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' + 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'prettier/prettier': 'error', + 'vue/attributes-order': [ + 'error', + { + order: [ + 'DEFINITION', + 'LIST_RENDERING', + 'CONDITIONALS', + 'RENDER_MODIFIERS', + 'GLOBAL', + 'UNIQUE', + 'TWO_WAY_BINDING', + 'OTHER_DIRECTIVES', + 'OTHER_ATTR', + 'EVENTS', + 'CONTENT' + ], + alphabetical: true + } + ] } -} \ No newline at end of file +} diff --git a/web/admin-spa/.prettierrc b/web/admin-spa/.prettierrc new file mode 100644 index 00000000..b31a3d5c --- /dev/null +++ b/web/admin-spa/.prettierrc @@ -0,0 +1,17 @@ +{ + "printWidth": 100, + "semi": false, + "singleQuote": true, + "trailingComma": "none", + "endOfLine": "auto", + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindConfig": "./tailwind.config.js", + + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "always", + "quoteProps": "as-needed", + "bracketSameLine": false, + "proseWrap": "preserve" +} diff --git a/web/admin-spa/package-lock.json b/web/admin-spa/package-lock.json index d73151b8..6ac43b20 100644 --- a/web/admin-spa/package-lock.json +++ b/web/admin-spa/package-lock.json @@ -19,16 +19,20 @@ }, "devDependencies": { "@vitejs/plugin-vue": "^4.5.2", + "@vue/eslint-config-prettier": "^10.2.0", "autoprefixer": "^10.4.16", "eslint": "^8.55.0", + "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-vue": "^9.19.2", "postcss": "^8.4.32", "prettier": "^3.1.1", + "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^3.3.6", "unplugin-auto-import": "^0.17.2", "unplugin-element-plus": "^0.8.0", "unplugin-vue-components": "^0.26.0", - "vite": "^5.0.8" + "vite": "^5.0.8", + "vite-plugin-checker": "^0.10.2" } }, "node_modules/@alloc/quick-lru": { @@ -54,6 +58,28 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -767,6 +793,19 @@ "node": ">=14" } }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@popperjs/core": { "name": "@sxzz/popperjs-es", "version": "2.11.7", @@ -1185,6 +1224,21 @@ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" }, + "node_modules/@vue/eslint-config-prettier": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-10.2.0.tgz", + "integrity": "sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2" + }, + "peerDependencies": { + "eslint": ">= 8.21.0", + "prettier": ">= 3.0.0" + } + }, "node_modules/@vue/reactivity": { "version": "3.5.18", "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.18.tgz", @@ -2062,6 +2116,53 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", + "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-plugin-vue": { "version": "9.33.0", "resolved": "https://registry.npmmirror.com/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz", @@ -2199,6 +2300,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3080,6 +3188,36 @@ "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", "license": "BSD-3-Clause" }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", @@ -3502,6 +3640,106 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4000,6 +4238,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.17.tgz", @@ -4068,6 +4322,58 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4121,6 +4427,19 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unimport": { "version": "3.14.6", "resolved": "https://registry.npmmirror.com/unimport/-/unimport-3.14.6.tgz", @@ -4492,6 +4811,147 @@ } } }, + "node_modules/vite-plugin-checker": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.10.2.tgz", + "integrity": "sha512-FX9U8TnIS6AGOlqmC6O2YmkJzcZJRrjA03UF7FOhcUJ7it3HmCoxcIPMcoHliBP6EFOuNzle9K4c0JL4suRPow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "chokidar": "^4.0.3", + "npm-run-path": "^6.0.0", + "picocolors": "^1.1.1", + "picomatch": "^4.0.3", + "strip-ansi": "^7.1.0", + "tiny-invariant": "^1.3.3", + "tinyglobby": "^0.2.14", + "vscode-uri": "^3.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "@biomejs/biome": ">=1.7", + "eslint": ">=7", + "meow": "^13.2.0", + "optionator": "^0.9.4", + "stylelint": ">=16", + "typescript": "*", + "vite": ">=2.0.0", + "vls": "*", + "vti": "*", + "vue-tsc": "~2.2.10 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@biomejs/biome": { + "optional": true + }, + "eslint": { + "optional": true + }, + "meow": { + "optional": true + }, + "optionator": { + "optional": true + }, + "stylelint": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vls": { + "optional": true + }, + "vti": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/vite-plugin-checker/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/vite-plugin-checker/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vite-plugin-checker/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite-plugin-checker/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vite-plugin-checker/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vue": { "version": "3.5.18", "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz", diff --git a/web/admin-spa/package.json b/web/admin-spa/package.json index a7429805..9cb15379 100644 --- a/web/admin-spa/package.json +++ b/web/admin-spa/package.json @@ -7,8 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "eslint src --ext .js,.vue", - "format": "prettier --write \"src/**/*.{js,vue,css}\"" + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", + "format": "prettier --write src/" }, "dependencies": { "@fortawesome/fontawesome-free": "^6.5.1", @@ -22,15 +22,19 @@ }, "devDependencies": { "@vitejs/plugin-vue": "^4.5.2", + "@vue/eslint-config-prettier": "^10.2.0", "autoprefixer": "^10.4.16", "eslint": "^8.55.0", + "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-vue": "^9.19.2", "postcss": "^8.4.32", "prettier": "^3.1.1", + "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^3.3.6", "unplugin-auto-import": "^0.17.2", "unplugin-element-plus": "^0.8.0", "unplugin-vue-components": "^0.26.0", - "vite": "^5.0.8" + "vite": "^5.0.8", + "vite-plugin-checker": "^0.10.2" } } diff --git a/web/admin-spa/postcss.config.js b/web/admin-spa/postcss.config.js index e99ebc2c..2b75bd8a 100644 --- a/web/admin-spa/postcss.config.js +++ b/web/admin-spa/postcss.config.js @@ -1,6 +1,6 @@ export default { plugins: { tailwindcss: {}, - autoprefixer: {}, - }, -} \ No newline at end of file + autoprefixer: {} + } +} diff --git a/web/admin-spa/src/App.vue b/web/admin-spa/src/App.vue index a986c126..a4c345a8 100644 --- a/web/admin-spa/src/App.vue +++ b/web/admin-spa/src/App.vue @@ -1,7 +1,7 @@