From f614d54ab503d4b32ed466962b8c6fb807827539 Mon Sep 17 00:00:00 2001 From: KevinLiao Date: Fri, 25 Jul 2025 09:34:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20APIKey=E5=88=97=E8=A1=A8=E8=B4=B9?= =?UTF-8?q?=E7=94=A8=E5=8F=8AToken=E6=98=BE=E7=A4=BA=E4=B8=8D=E5=87=86?= =?UTF-8?q?=E7=A1=AE=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=8C=E7=9B=AE=E5=89=8D?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=80=BB=E6=95=B0=20feat:=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0APIKey=E8=BF=87=E6=9C=9F=E8=AE=BE=E7=BD=AE=EF=BC=8C?= =?UTF-8?q?=E4=BB=A5=E5=8F=8A=E5=88=B0=E6=9C=9F=E7=BB=AD=E6=9C=9F=E7=9A=84?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/index.js | 363 ++++++++++- docs/UPGRADE_GUIDE.md | 205 ++++++ docs/api-key-expiry-guide.md | 187 ++++++ docs/data-encryption-handling.md | 177 ++++++ package-lock.json | 131 ++-- package.json | 14 +- scripts/data-transfer-enhanced.js | 994 ++++++++++++++++++++++++++++++ scripts/data-transfer.js | 517 ++++++++++++++++ scripts/debug-redis-keys.js | 123 ++++ scripts/fix-inquirer.js | 32 + scripts/fix-usage-stats.js | 227 +++++++ scripts/migrate-apikey-expiry.js | 191 ++++++ scripts/test-apikey-expiry.js | 159 +++++ scripts/test-import-encryption.js | 181 ++++++ src/models/redis.js | 10 +- src/routes/admin.js | 92 ++- src/services/apiKeyService.js | 9 +- web/admin/app.js | 238 ++++++- web/admin/index.html | 148 +++++ 19 files changed, 3908 insertions(+), 90 deletions(-) create mode 100644 docs/UPGRADE_GUIDE.md create mode 100644 docs/api-key-expiry-guide.md create mode 100644 docs/data-encryption-handling.md create mode 100644 scripts/data-transfer-enhanced.js create mode 100644 scripts/data-transfer.js create mode 100644 scripts/debug-redis-keys.js create mode 100644 scripts/fix-inquirer.js create mode 100644 scripts/fix-usage-stats.js create mode 100644 scripts/migrate-apikey-expiry.js create mode 100644 scripts/test-apikey-expiry.js create mode 100644 scripts/test-import-encryption.js diff --git a/cli/index.js b/cli/index.js index 78f6f959..586cf20b 100644 --- a/cli/index.js +++ b/cli/index.js @@ -4,7 +4,7 @@ const { Command } = require('commander'); const inquirer = require('inquirer'); const chalk = require('chalk'); const ora = require('ora'); -const Table = require('table').table; +const { table } = require('table'); const bcrypt = require('bcryptjs'); const crypto = require('crypto'); const fs = require('fs'); @@ -54,6 +54,43 @@ program }); +// 🔑 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' } + ] + }]); + + switch (action) { + case 'list': + await listApiKeys(); + break; + case 'update-expiry': + await updateApiKeyExpiry(); + break; + case 'renew': + await renewApiKeys(); + break; + case 'delete': + await deleteApiKey(); + break; + } + + await redis.disconnect(); + }); + // 📊 系统状态 program .command('status') @@ -201,6 +238,329 @@ async function createInitialAdmin() { +// API Key 管理功能 +async function listApiKeys() { + const spinner = ora('正在获取 API Keys...').start(); + + try { + const apiKeys = await apiKeyService.getAllApiKeys(); + spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`); + + if (apiKeys.length === 0) { + console.log(styles.warning('没有找到任何 API Keys')); + return; + } + + const tableData = [ + ['名称', 'API Key', '状态', '过期时间', '使用量', 'Token限制'] + ]; + + 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()})`); + } else { + const daysLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24)); + if (daysLeft <= 7) { + expiryStatus = styles.warning(`${daysLeft}天后过期 (${expiresAt.toLocaleDateString()})`); + } else { + expiryStatus = styles.success(`${expiresAt.toLocaleDateString()}`); + } + } + } + + tableData.push([ + key.name, + 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)); + + } catch (error) { + spinner.fail('获取 API Keys 失败'); + console.error(styles.error(error.message)); + } +} + +async function updateApiKeyExpiry() { + try { + // 获取所有 API Keys + const apiKeys = await apiKeyService.getAllApiKeys(); + + if (apiKeys.length === 0) { + 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 + })) + }]); + + 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' } + ] + }]); + + let newExpiresAt = null; + + if (expiryOption === 'never') { + newExpiresAt = null; + } else if (expiryOption === 'custom') { + const { customDate, customTime } = await inquirer.prompt([ + { + type: 'input', + name: 'customDate', + message: '输入日期 (YYYY-MM-DD):', + default: new Date().toISOString().split('T')[0], + validate: input => { + const date = new Date(input); + return !isNaN(date.getTime()) || '请输入有效的日期格式'; + } + }, + { + type: 'input', + name: 'customTime', + message: '输入时间 (HH:MM):', + default: '00:00', + validate: input => { + return /^\d{2}:\d{2}$/.test(input) || '请输入有效的时间格式 (HH:MM)'; + } + } + ]); + + newExpiresAt = new Date(`${customDate}T${customTime}:00`).toISOString(); + } else { + // 计算新的过期时间 + const now = new Date(); + const durations = { + '1m': 60 * 1000, + '1h': 60 * 60 * 1000, + '1d': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '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(); + } + + // 确认修改 + const confirmMsg = newExpiresAt + ? `确认将过期时间修改为: ${new Date(newExpiresAt).toLocaleString()}?` + : '确认设置为永不过期?'; + + const { confirmed } = await inquirer.prompt([{ + type: 'confirm', + name: 'confirmed', + message: confirmMsg, + default: true + }]); + + if (!confirmed) { + 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)); + } + + } catch (error) { + console.error(styles.error('操作失败:', error.message)); + } +} + +async function renewApiKeys() { + 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); + + // 筛选即将过期的 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(); + + if (expiringKeys.length === 0) { + console.log(styles.info('没有即将过期的 API Keys(7天内)')); + return; + } + + 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' } + ] + }]); + + if (renewOption.startsWith('all')) { + 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 }); + } catch (error) { + renewSpinner.fail(`续期 ${key.name} 失败: ${error.message}`); + } + } + + 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' } + ] + }]); + + if (action !== 'skip') { + 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} 天`)); + } catch (error) { + console.log(styles.error(`❌ 续期失败: ${error.message}`)); + } + } + } + } + + } catch (error) { + spinner.fail('操作失败'); + console.error(styles.error(error.message)); + } +} + +async function deleteApiKey() { + try { + const apiKeys = await apiKeyService.getAllApiKeys(); + + if (apiKeys.length === 0) { + 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 + })) + }]); + + if (selectedKeys.length === 0) { + 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 + }]); + + if (!confirmed) { + console.log(styles.info('已取消删除')); + return; + } + + const spinner = ora('正在删除 API Keys...').start(); + let successCount = 0; + + for (const keyId of selectedKeys) { + try { + await apiKeyService.deleteApiKey(keyId); + successCount++; + } catch (error) { + spinner.fail(`删除失败: ${error.message}`); + } + } + + spinner.succeed(`成功删除 ${successCount}/${selectedKeys.length} 个 API Keys`); + + } catch (error) { + console.error(styles.error('删除失败:', error.message)); + } +} + async function listClaudeAccounts() { const spinner = ora('正在获取 Claude 账户...').start(); @@ -251,6 +611,7 @@ 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 status - 查看系统状态'); console.log('\n使用 --help 查看详细帮助信息'); } \ No newline at end of file diff --git a/docs/UPGRADE_GUIDE.md b/docs/UPGRADE_GUIDE.md new file mode 100644 index 00000000..84f0c9a4 --- /dev/null +++ b/docs/UPGRADE_GUIDE.md @@ -0,0 +1,205 @@ +# 升级指南 - API Key 有效期功能 + +本指南说明如何从旧版本安全升级到支持 API Key 有效期限制的新版本。 + +## 升级前准备 + +### 1. 备份现有数据 + +在升级前,强烈建议备份您的生产数据: + +```bash +# 导出所有数据(包含敏感信息) +npm run data:export -- --output=prod-backup-$(date +%Y%m%d).json + +# 或导出脱敏数据(用于测试环境) +npm run data:export:sanitized -- --output=prod-backup-sanitized-$(date +%Y%m%d).json +``` + +### 2. 确认备份完整性 + +检查导出的文件,确保包含所有必要的数据: + +```bash +# 查看备份文件信息 +cat prod-backup-*.json | jq '.metadata' + +# 查看数据统计 +cat prod-backup-*.json | jq '.data | keys' +``` + +## 升级步骤 + +### 1. 停止服务 + +```bash +# 停止 Claude Relay Service +npm run service:stop + +# 或如果使用 Docker +docker-compose down +``` + +### 2. 更新代码 + +```bash +# 拉取最新代码 +git pull origin main + +# 安装依赖 +npm install + +# 更新 Web 界面依赖 +npm run install:web +``` + +### 3. 运行数据迁移 + +为现有的 API Key 设置默认 30 天有效期: + +```bash +# 先进行模拟运行,查看将要修改的数据 +npm run migrate:apikey-expiry:dry + +# 确认无误后,执行实际迁移 +npm run migrate:apikey-expiry +``` + +如果您想设置不同的默认有效期: + +```bash +# 设置 90 天有效期 +npm run migrate:apikey-expiry -- --days=90 +``` + +### 4. 启动服务 + +```bash +# 启动服务 +npm run service:start:daemon + +# 或使用 Docker +docker-compose up -d +``` + +### 5. 验证升级 + +1. 登录 Web 管理界面 +2. 检查 API Key 列表,确认显示过期时间列 +3. 测试创建新的 API Key,确认可以设置过期时间 +4. 测试续期功能是否正常工作 + +## 从生产环境导入数据(用于测试) + +如果您需要在测试环境中使用生产数据: + +### 1. 在生产环境导出数据 + +```bash +# 导出脱敏数据(推荐用于测试) +npm run data:export:sanitized -- --output=prod-export.json + +# 或只导出特定类型的数据 +npm run data:export -- --types=apikeys,accounts --sanitize --output=prod-partial.json +``` + +### 2. 传输文件到测试环境 + +使用安全的方式传输文件,如 SCP: + +```bash +scp prod-export.json user@test-server:/path/to/claude-relay-service/ +``` + +### 3. 在测试环境导入数据 + +```bash +# 导入数据,遇到冲突时询问 +npm run data:import -- --input=prod-export.json + +# 或跳过所有冲突 +npm run data:import -- --input=prod-export.json --skip-conflicts + +# 或强制覆盖所有数据(谨慎使用) +npm run data:import -- --input=prod-export.json --force +``` + +## 回滚方案 + +如果升级后遇到问题,可以按以下步骤回滚: + +### 1. 停止服务 + +```bash +npm run service:stop +``` + +### 2. 恢复代码 + +```bash +# 切换到之前的版本 +git checkout + +# 重新安装依赖 +npm install +``` + +### 3. 恢复数据(如需要) + +```bash +# 从备份恢复数据 +npm run data:import -- --input=prod-backup-.json --force +``` + +### 4. 重启服务 + +```bash +npm run service:start:daemon +``` + +## 注意事项 + +1. **数据迁移是幂等的**:迁移脚本可以安全地多次运行,已有过期时间的 API Key 不会被修改。 + +2. **过期的 API Key 处理**: + - 过期的 API Key 会被自动禁用,而不是删除 + - 管理员可以通过续期功能重新激活过期的 Key + +3. **定时任务**: + - 系统会每小时自动检查并禁用过期的 API Key + - 该任务在 `config.system.cleanupInterval` 中配置 + +4. **API 兼容性**: + - 新增的过期时间功能完全向后兼容 + - 现有的 API 调用不会受到影响 + +## 常见问题 + +### Q: 如果不想某些 API Key 过期怎么办? + +A: 您可以通过 Web 界面将特定 API Key 设置为"永不过期",或在续期时选择"设为永不过期"。 + +### Q: 迁移脚本会影响已经设置了过期时间的 API Key 吗? + +A: 不会。迁移脚本只会处理没有设置过期时间的 API Key。 + +### Q: 如何批量修改 API Key 的过期时间? + +A: 您可以修改迁移脚本,或使用数据导出/导入工具批量处理。 + +### Q: 导出的脱敏数据可以用于生产环境吗? + +A: 不建议。脱敏数据缺少关键的认证信息(如 OAuth tokens),仅适用于测试环境。 + +## 技术支持 + +如遇到问题,请检查: + +1. 服务日志:`npm run service:logs` +2. Redis 连接:确保 Redis 服务正常运行 +3. 配置文件:检查 `.env` 和 `config/config.js` + +如需进一步帮助,请提供: +- 错误日志 +- 使用的命令 +- 系统环境信息 \ No newline at end of file diff --git a/docs/api-key-expiry-guide.md b/docs/api-key-expiry-guide.md new file mode 100644 index 00000000..68f64bb0 --- /dev/null +++ b/docs/api-key-expiry-guide.md @@ -0,0 +1,187 @@ +# API Key 过期时间管理指南 + +## 概述 + +Claude Relay Service 支持为 API Keys 设置过期时间,提供了灵活的过期管理功能,方便进行权限控制和安全管理。 + +## 功能特性 + +- ✅ 创建时设置过期时间 +- ✅ 随时修改过期时间 +- ✅ 自动禁用过期的 Keys +- ✅ 手动续期功能 +- ✅ 批量续期支持 +- ✅ Web 界面和 CLI 双重管理 + +## CLI 管理工具 + +### 1. 查看 API Keys + +```bash +npm run cli keys +# 选择 "📋 查看所有 API Keys" +``` + +显示内容包括: +- 名称和部分 Key +- 活跃/禁用状态 +- 过期时间(带颜色提示) +- Token 使用量 +- Token 限制 + +### 2. 修改过期时间 + +```bash +npm run cli keys +# 选择 "🔧 修改 API Key 过期时间" +``` + +支持的过期选项: +- ⏰ **1小时后**(测试用) +- 📅 **1天后** +- 📅 **7天后** +- 📅 **30天后** +- 📅 **90天后** +- 📅 **365天后** +- ♾️ **永不过期** +- 🎯 **自定义日期时间** + +### 3. 批量续期 + +```bash +npm run cli keys +# 选择 "🔄 续期即将过期的 API Key" +``` + +功能: +- 查找7天内即将过期的 Keys +- 支持全部续期30天或90天 +- 支持逐个选择续期 + +### 4. 删除 API Keys + +```bash +npm run cli keys +# 选择 "🗑️ 删除 API Key" +``` + +## Web 界面功能 + +### 创建时设置过期 + +在创建 API Key 时,可以选择: +- 永不过期 +- 1天、7天、30天、90天、180天、365天 +- 自定义日期 + +### 查看过期状态 + +API Key 列表中显示: +- 🔴 已过期(红色) +- 🟡 即将过期(7天内,黄色) +- 🟢 正常(绿色) +- ♾️ 永不过期 + +### 手动续期 + +对于已过期的 API Keys: +1. 点击"续期"按钮 +2. 选择新的过期时间 +3. 确认更新 + +## 自动清理机制 + +系统每小时自动运行清理任务: +- 检查所有 API Keys 的过期时间 +- 将过期的 Keys 标记为禁用(`isActive = false`) +- 不删除数据,保留历史记录 +- 记录清理日志 + +## 测试工具 + +### 1. 快速测试脚本 + +```bash +node scripts/test-apikey-expiry.js +``` + +创建5个测试 Keys: +- 已过期(1天前) +- 1小时后过期 +- 1天后过期 +- 7天后过期 +- 永不过期 + +### 2. 迁移脚本 + +为现有 API Keys 设置默认30天过期时间: + +```bash +# 预览(不实际修改) +npm run migrate:apikey-expiry:dry + +# 执行迁移 +npm run migrate:apikey-expiry +``` + +## 使用场景 + +### 1. 临时访问 + +为临时用户或测试创建短期 Key: +```bash +# 创建1天有效期的测试 Key +# 在 Web 界面或 CLI 中选择"1天" +``` + +### 2. 定期更新 + +为安全考虑,定期更新 Keys: +```bash +# 每30天自动过期,需要续期 +# 创建时选择"30天" +``` + +### 3. 长期合作 + +为可信任的长期用户: +```bash +# 选择"365天"或"永不过期" +``` + +### 4. 测试过期功能 + +快速测试过期验证: +```bash +# 1. 创建1小时后过期的 Key +npm run cli keys +# 选择修改过期时间 -> 选择测试 Key -> 1小时后 + +# 2. 等待或手动触发清理 +# 3. 验证 API 调用被拒绝 +``` + +## API 响应 + +过期的 API Key 调用时返回: +```json +{ + "error": "Unauthorized", + "message": "Invalid or inactive API key" +} +``` + +## 最佳实践 + +1. **定期审查**:定期检查即将过期的 Keys +2. **提前通知**:在过期前通知用户续期 +3. **分级管理**:根据用户级别设置不同过期策略 +4. **测试验证**:新功能上线前充分测试过期机制 +5. **备份恢复**:使用数据导出工具备份 Key 信息 + +## 注意事项 + +- 过期的 Keys 不会被删除,只是禁用 +- 可以随时续期已过期的 Keys +- 修改过期时间立即生效 +- 清理任务每小时运行一次 \ No newline at end of file diff --git a/docs/data-encryption-handling.md b/docs/data-encryption-handling.md new file mode 100644 index 00000000..afe358d8 --- /dev/null +++ b/docs/data-encryption-handling.md @@ -0,0 +1,177 @@ +# 数据导入/导出加密处理指南 + +## 概述 + +Claude Relay Service 使用 AES-256-CBC 加密算法来保护敏感数据。本文档详细说明了数据导入/导出工具如何处理加密和未加密的数据。 + +## 加密机制 + +### 加密的数据类型 + +1. **Claude 账户** + - email + - password + - accessToken + - refreshToken + - claudeAiOauth (OAuth 数据) + - 使用 salt: `'salt'` + +2. **Gemini 账户** + - geminiOauth (OAuth 数据) + - accessToken + - refreshToken + - 使用 salt: `'gemini-account-salt'` + +### 加密格式 + +加密后的数据格式:`{iv}:{encryptedData}` +- `iv`: 16字节的初始化向量(hex格式) +- `encryptedData`: 加密后的数据(hex格式) + +## 导出功能 + +### 1. 解密导出(默认) +```bash +npm run data:export:enhanced +# 或 +node scripts/data-transfer-enhanced.js export --decrypt=true +``` + +- **用途**:数据迁移到其他环境 +- **特点**: + - `metadata.decrypted = true` + - 敏感数据以明文形式导出 + - 便于在不同加密密钥的环境间迁移 + +### 2. 加密导出 +```bash +npm run data:export:encrypted +# 或 +node scripts/data-transfer-enhanced.js export --decrypt=false +``` + +- **用途**:备份或在相同加密密钥的环境间传输 +- **特点**: + - `metadata.decrypted = false` + - 保持数据的加密状态 + - 必须在相同的 ENCRYPTION_KEY 环境下才能使用 + +### 3. 脱敏导出 +```bash +node scripts/data-transfer-enhanced.js export --sanitize +``` + +- **用途**:分享数据结构或调试 +- **特点**: + - `metadata.sanitized = true` + - 敏感字段被替换为 `[REDACTED]` + - 不能用于实际导入 + +## 导入功能 + +### 自动加密处理逻辑 + +```javascript +if (importData.metadata.decrypted && !importData.metadata.sanitized) { + // 数据已解密且不是脱敏的,需要重新加密 + // 自动加密所有敏感字段 +} else { + // 数据已加密或是脱敏的,保持原样 +} +``` + +### 导入场景 + +#### 场景 1:导入解密的数据 +- **输入**:`metadata.decrypted = true` +- **处理**:自动加密所有敏感字段 +- **结果**:数据以加密形式存储在 Redis + +#### 场景 2:导入加密的数据 +- **输入**:`metadata.decrypted = false` +- **处理**:直接存储,不做加密处理 +- **结果**:保持原有加密状态 +- **注意**:必须使用相同的 ENCRYPTION_KEY + +#### 场景 3:导入脱敏的数据 +- **输入**:`metadata.sanitized = true` +- **处理**:警告并询问是否继续 +- **结果**:导入但缺少敏感数据,账户可能无法正常工作 + +## 使用示例 + +### 1. 跨环境迁移 +```bash +# 在生产环境导出(解密) +npm run data:export:enhanced -- --output=prod-data.json + +# 在测试环境导入(自动加密) +npm run data:import:enhanced -- --input=prod-data.json +``` + +### 2. 同环境备份恢复 +```bash +# 备份(保持加密) +npm run data:export:encrypted -- --output=backup.json + +# 恢复(保持加密) +npm run data:import:enhanced -- --input=backup.json +``` + +### 3. 选择性导入 +```bash +# 跳过已存在的数据 +npm run data:import:enhanced -- --input=data.json --skip-conflicts + +# 强制覆盖所有数据 +npm run data:import:enhanced -- --input=data.json --force +``` + +## 安全建议 + +1. **加密密钥管理** + - 使用强随机密钥(至少32字符) + - 不同环境使用不同的密钥 + - 定期轮换密钥 + +2. **导出文件保护** + - 解密的导出文件包含明文敏感数据 + - 应立即加密存储或传输 + - 使用后及时删除 + +3. **权限控制** + - 限制导出/导入工具的访问权限 + - 审计所有数据导出操作 + - 使用脱敏导出进行非生产用途 + +## 故障排除 + +### 常见问题 + +1. **导入后账户无法使用** + - 检查 ENCRYPTION_KEY 是否正确 + - 确认不是导入了脱敏数据 + - 验证加密字段格式是否正确 + +2. **加密/解密失败** + - 确保 ENCRYPTION_KEY 长度为32字符 + - 检查加密数据格式 `{iv}:{data}` + - 查看日志中的解密警告 + +3. **数据不完整** + - 检查导出时是否使用了 --types 限制 + - 确认 Redis 连接正常 + - 验证账户前缀(claude:account: vs claude_account:) + +## 测试工具 + +运行测试脚本验证加密处理: +```bash +node scripts/test-import-encryption.js +``` + +该脚本会: +1. 创建测试导出文件(加密和解密版本) +2. 显示加密前后的数据对比 +3. 提供测试导入命令 +4. 验证加密/解密功能 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 63c3e441..2142691c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "google-auth-library": "^10.1.0", "helmet": "^7.1.0", "https-proxy-agent": "^7.0.2", - "inquirer": "^9.2.15", + "inquirer": "^8.2.6", "ioredis": "^5.3.2", "morgan": "^1.10.0", "ora": "^5.4.1", @@ -773,15 +773,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@inquirer/figures": { - "version": "1.0.12", - "resolved": "https://registry.npmmirror.com/@inquirer/figures/-/figures-1.0.12.tgz", - "integrity": "sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@ioredis/commands": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.2.0.tgz", @@ -2097,7 +2088,7 @@ }, "node_modules/chardet": { "version": "0.7.0", - "resolved": "https://registry.npmmirror.com/chardet/-/chardet-0.7.0.tgz", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "license": "MIT" }, @@ -2187,12 +2178,12 @@ } }, "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", "license": "ISC", "engines": { - "node": ">= 12" + "node": ">= 10" } }, "node_modules/cliui": { @@ -3107,7 +3098,7 @@ }, "node_modules/external-editor": { "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/external-editor/-/external-editor-3.1.0.tgz", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", "license": "MIT", "dependencies": { @@ -3211,6 +3202,30 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3888,26 +3903,29 @@ "license": "ISC" }, "node_modules/inquirer": { - "version": "9.3.7", - "resolved": "https://registry.npmmirror.com/inquirer/-/inquirer-9.3.7.tgz", - "integrity": "sha512-LJKFHCSeIRq9hanN14IlOtPSTe3lNES7TYDTE2xxdAy1LS5rYphajK1qtwvj3YmQXvvk0U2Vbmcni8P9EIQW9w==", + "version": "8.2.6", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", + "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", "license": "MIT", "dependencies": { - "@inquirer/figures": "^1.0.3", - "ansi-escapes": "^4.3.2", - "cli-width": "^4.1.0", - "external-editor": "^3.1.0", - "mute-stream": "1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", "ora": "^5.4.1", - "run-async": "^3.0.0", - "rxjs": "^7.8.1", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" }, "engines": { - "node": ">=18" + "node": ">=12.0.0" } }, "node_modules/ioredis": { @@ -5005,6 +5023,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -5283,13 +5307,10 @@ "license": "MIT" }, "node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -5600,7 +5621,7 @@ }, "node_modules/os-tmpdir": { "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "license": "MIT", "engines": { @@ -6169,9 +6190,9 @@ } }, "node_modules/run-async": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/run-async/-/run-async-3.0.0.tgz", - "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", "license": "MIT", "engines": { "node": ">=0.12.0" @@ -6203,7 +6224,7 @@ }, "node_modules/rxjs": { "version": "7.8.2", - "resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", "dependencies": { @@ -6894,9 +6915,15 @@ "dev": true, "license": "MIT" }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.0.33", - "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.0.33.tgz", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", "license": "MIT", "dependencies": { @@ -6956,7 +6983,7 @@ }, "node_modules/tslib": { "version": "2.8.1", - "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, @@ -7263,7 +7290,7 @@ }, "node_modules/wrap-ansi": { "version": "6.2.0", - "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "license": "MIT", "dependencies": { @@ -7354,18 +7381,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", - "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/package.json b/package.json index 0aa80490..4fd9eb4f 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,17 @@ "lint": "eslint src/**/*.js", "docker:build": "docker build -t claude-relay-service .", "docker:up": "docker-compose up -d", - "docker:down": "docker-compose down" + "docker:down": "docker-compose down", + "migrate:apikey-expiry": "node scripts/migrate-apikey-expiry.js", + "migrate:apikey-expiry:dry": "node scripts/migrate-apikey-expiry.js --dry-run", + "migrate:fix-usage-stats": "node scripts/fix-usage-stats.js", + "data:export": "node scripts/data-transfer.js export", + "data:import": "node scripts/data-transfer.js import", + "data:export:sanitized": "node scripts/data-transfer.js export --sanitize", + "data:export:enhanced": "node scripts/data-transfer-enhanced.js export", + "data:export:encrypted": "node scripts/data-transfer-enhanced.js export --decrypt=false", + "data:import:enhanced": "node scripts/data-transfer-enhanced.js import", + "data:debug": "node scripts/debug-redis-keys.js" }, "dependencies": { "axios": "^1.6.0", @@ -40,7 +50,7 @@ "google-auth-library": "^10.1.0", "helmet": "^7.1.0", "https-proxy-agent": "^7.0.2", - "inquirer": "^9.2.15", + "inquirer": "^8.2.6", "ioredis": "^5.3.2", "morgan": "^1.10.0", "ora": "^5.4.1", diff --git a/scripts/data-transfer-enhanced.js b/scripts/data-transfer-enhanced.js new file mode 100644 index 00000000..59390266 --- /dev/null +++ b/scripts/data-transfer-enhanced.js @@ -0,0 +1,994 @@ +#!/usr/bin/env node + +/** + * 增强版数据导出/导入工具 + * 支持加密数据的处理 + */ + +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 = {}; + +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'); + }); + }); +} + +// Claude 账户解密函数 +function decryptClaudeData(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; + } + return encryptedData; + } catch (error) { + logger.warn(`⚠️ Failed to decrypt data: ${error.message}`); + return encryptedData; + } +} + +// Gemini 账户解密函数 +function decryptGeminiData(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; + } + return encryptedData; + } catch (error) { + 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; +} + +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; +} + +// 导出使用统计数据 +async function exportUsageStats(keyId) { + try { + const stats = { + total: {}, + daily: {}, + monthly: {}, + hourly: {}, + models: {} + }; + + // 导出总统计 + 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(); + 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); + if (dailyData && Object.keys(dailyData).length > 0) { + 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); + if (monthlyData && Object.keys(monthlyData).length > 0) { + 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); + if (hourlyData && Object.keys(hourlyData).length > 0) { + stats.hourly[hourKey] = hourlyData; + } + } + + // 导出模型统计 + // 每日模型统计 + 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})$/); + if (match) { + 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; + } + } + } + + // 每月模型统计 + 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})$/); + if (match) { + 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; + } + } + } + + return stats; + } catch (error) { + 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.total && Object.keys(stats.total).length > 0) { + for (const [field, value] of Object.entries(stats.total)) { + pipeline.hset(`usage:${keyId}`, field, value); + } + 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); + } + 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); + } + 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); + } + importCount++; + } + } + + // 导入模型统计 + if (stats.models) { + for (const [model, modelStats] of Object.entries(stats.models)) { + // 每日模型统计 + 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); + } + 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); + } + importCount++; + } + } + } + } + + 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}`); + } +} + +// 数据脱敏函数 +function sanitizeData(data, type) { + const sanitized = { ...data }; + + switch (type) { + case 'apikey': + if (sanitized.apiKey) { + sanitized.apiKey = sanitized.apiKey.substring(0, 10) + '...[REDACTED]'; + } + 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; + + 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; + + case 'admin': + if (sanitized.password) sanitized.password = '[REDACTED]'; + break; + } + + 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 = { + metadata: { + version: '2.0', + exportDate: new Date().toISOString(), + sanitized: shouldSanitize, + decrypted: shouldDecrypt, + 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 = []; + + for (const key of keys) { + 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; + + // 导出使用统计数据 + if (keyId && (types.includes('all') || types.includes('stats'))) { + data.usageStats = await exportUsageStats(keyId); + } + + apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data); + } + } + + exportData.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 = []; + + for (const key of keys) { + 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.claudeAiOauth) { + const decrypted = decryptClaudeData(data.claudeAiOauth); + try { + data.claudeAiOauth = JSON.parse(decrypted); + } catch (e) { + data.claudeAiOauth = decrypted; + } + } + } + + accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data); + } + } + + exportData.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 = []; + + for (const key of geminiKeys) { + 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); + try { + data.geminiOauth = JSON.parse(decrypted); + } catch (e) { + data.geminiOauth = decrypted; + } + } + if (data.accessToken) data.accessToken = decryptGeminiData(data.accessToken); + if (data.refreshToken) data.refreshToken = decryptGeminiData(data.refreshToken); + } + + geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data); + } + } + + exportData.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 = []; + + for (const key of keys) { + 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); + } + } + + exportData.data.admins = admins; + logger.success(`✅ Exported ${admins.length} admins`); + } + + // 导出全局模型统计(如果需要) + if (types.includes('all') || types.includes('stats')) { + logger.info('📤 Exporting global model statistics...'); + const globalStats = { + daily: {}, + monthly: {}, + hourly: {} + }; + + // 导出全局每日模型统计 + 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})$/); + if (match) { + 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; + } + } + } + + // 导出全局每月模型统计 + 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})$/); + if (match) { + 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; + } + } + } + + // 导出全局每小时模型统计 + 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})$/); + if (match) { + 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; + } + } + } + + exportData.data.globalModelStats = globalStats; + logger.success(`✅ Exported global model statistics`); + } + + // 写入文件 + 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}`); + } + if (exportData.data.claudeAccounts) { + console.log(`Claude Accounts: ${exportData.data.claudeAccounts.length}`); + } + if (exportData.data.geminiAccounts) { + console.log(`Gemini Accounts: ${exportData.data.geminiAccounts.length}`); + } + if (exportData.data.admins) { + console.log(`Admins: ${exportData.data.admins.length}`); + } + console.log('='.repeat(60)); + + if (shouldSanitize) { + logger.warn('⚠️ Sensitive data has been sanitized in this export.'); + } + if (shouldDecrypt) { + logger.info('🔓 Encrypted data has been decrypted for portability.'); + } + + } catch (error) { + logger.error('💥 Export failed:', error); + process.exit(1); + } finally { + await redis.disconnect(); + rl.close(); + } +} + +// 显示帮助信息 +function showHelp() { + console.log(` +Enhanced Data Transfer Tool for Claude Relay Service + +This tool handles encrypted data export/import between environments. + +Usage: + node scripts/data-transfer-enhanced.js [options] + +Commands: + export Export data from Redis to a JSON file + import Import data from a JSON file to Redis + +Export Options: + --output=FILE Output filename (default: backup-YYYY-MM-DD.json) + --types=TYPE,... Data types: apikeys,accounts,admins,stats,all (default: all) + stats: Include usage statistics with API keys + --sanitize Remove sensitive data from export + --decrypt=false Keep data encrypted (default: true - decrypt for portability) + +Import Options: + --input=FILE Input filename (required) + --force Overwrite existing data without asking + --skip-conflicts Skip conflicting data without asking + +Important Notes: + - The tool automatically handles encryption/decryption during import + - If importing decrypted data, it will be re-encrypted automatically + - If importing encrypted data, it will be stored as-is + - Sanitized exports cannot be properly imported (missing sensitive data) + +Examples: + # Export all data with decryption (for migration) + node scripts/data-transfer-enhanced.js export + + # Export without decrypting (for backup) + node scripts/data-transfer-enhanced.js export --decrypt=false + + # Import data (auto-handles encryption) + node scripts/data-transfer-enhanced.js import --input=backup.json + + # Import with force overwrite + node scripts/data-transfer-enhanced.js import --input=backup.json --force +`); +} + +// 导入数据 +async function importData() { + try { + const inputFile = params.input; + if (!inputFile) { + 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 fileContent = await fs.readFile(inputFile, 'utf8'); + const importData = JSON.parse(fileContent); + + // 验证文件格式 + if (!importData.metadata || !importData.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?'); + if (!proceed) { + 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}`); + } + if (importData.data.claudeAccounts) { + console.log(`Claude Accounts to import: ${importData.data.claudeAccounts.length}`); + } + if (importData.data.geminiAccounts) { + console.log(`Gemini Accounts to import: ${importData.data.geminiAccounts.length}`); + } + if (importData.data.admins) { + console.log(`Admins to import: ${importData.data.admins.length}`); + } + console.log('='.repeat(60) + '\n'); + + // 确认导入 + const confirmed = await askConfirmation('⚠️ Proceed with import?'); + if (!confirmed) { + logger.info('❌ Import cancelled'); + return; + } + + // 连接 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) { + try { + 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; + } else { + const overwrite = await askConfirmation(`API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?`); + if (!overwrite) { + stats.skipped++; + continue; + } + } + } + + // 保存使用统计数据以便单独导入 + const usageStats = apiKey.usageStats; + + // 从apiKey对象中删除usageStats字段,避免存储到主键中 + const apiKeyData = { ...apiKey }; + delete apiKeyData.usageStats; + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline(); + for (const [field, value] of Object.entries(apiKeyData)) { + pipeline.hset(`apikey:${apiKey.id}`, field, value); + } + await pipeline.exec(); + + // 更新哈希映射 + if (apiKey.apiKey && !importData.metadata.sanitized) { + await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id); + } + + // 导入使用统计数据 + if (usageStats) { + await importUsageStats(apiKey.id, usageStats); + } + + 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++; + } + } + } + + // 导入 Claude 账户 + if (importData.data.claudeAccounts) { + logger.info('\n📥 Importing Claude accounts...'); + for (const account of importData.data.claudeAccounts) { + try { + 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; + } else { + const overwrite = await askConfirmation(`Claude account "${account.name}" (${account.id}) exists. Overwrite?`); + if (!overwrite) { + stats.skipped++; + continue; + } + } + } + + // 复制账户数据以避免修改原始数据 + 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 (accountData.claudeAiOauth) { + // 如果是对象,先序列化再加密 + const oauthStr = typeof accountData.claudeAiOauth === 'object' ? + JSON.stringify(accountData.claudeAiOauth) : accountData.claudeAiOauth; + accountData.claudeAiOauth = encryptClaudeData(oauthStr); + } + } + + // 使用 hset 存储到哈希表 + 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)); + } else { + pipeline.hset(`claude:account:${account.id}`, field, value); + } + } + 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++; + } + } + } + + // 导入 Gemini 账户 + if (importData.data.geminiAccounts) { + logger.info('\n📥 Importing Gemini accounts...'); + for (const account of importData.data.geminiAccounts) { + try { + 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; + } else { + const overwrite = await askConfirmation(`Gemini account "${account.name}" (${account.id}) exists. Overwrite?`); + if (!overwrite) { + stats.skipped++; + continue; + } + } + } + + // 复制账户数据以避免修改原始数据 + const accountData = { ...account }; + + // 如果数据已解密且不是脱敏数据,需要重新加密 + if (importData.metadata.decrypted && !importData.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); + } + if (accountData.accessToken) accountData.accessToken = encryptGeminiData(accountData.accessToken); + if (accountData.refreshToken) accountData.refreshToken = encryptGeminiData(accountData.refreshToken); + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline(); + for (const [field, value] of Object.entries(accountData)) { + pipeline.hset(`gemini_account:${account.id}`, field, value); + } + 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++; + } + } + } + + // 导入管理员账户 + if (importData.data.admins) { + logger.info('\n📥 Importing admins...'); + for (const admin of importData.data.admins) { + try { + 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; + } else { + const overwrite = await askConfirmation(`Admin "${admin.username}" (${admin.id}) exists. Overwrite?`); + if (!overwrite) { + stats.skipped++; + continue; + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline(); + for (const [field, value] of Object.entries(admin)) { + pipeline.hset(`admin:${admin.id}`, field, value); + } + await pipeline.exec(); + + // 更新用户名映射 + 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++; + } + } + } + + // 导入全局模型统计 + if (importData.data.globalModelStats) { + logger.info('\n📥 Importing global model statistics...'); + try { + const globalStats = importData.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); + } + 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); + } + 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); + } + 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++; + } + } + + // 显示导入结果 + 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); + } finally { + await redis.disconnect(); + rl.close(); + } +} + +// 主函数 +async function main() { + if (!command || command === '--help' || command === 'help') { + showHelp(); + process.exit(0); + } + + switch (command) { + case 'export': + await exportData(); + break; + + case 'import': + await importData(); + break; + + default: + 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 diff --git a/scripts/data-transfer.js b/scripts/data-transfer.js new file mode 100644 index 00000000..86cc9f0b --- /dev/null +++ b/scripts/data-transfer.js @@ -0,0 +1,517 @@ +#!/usr/bin/env node + +/** + * 数据导出/导入工具 + * + * 使用方法: + * 导出: 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: 导出时脱敏敏感数据 + * --force: 导入时强制覆盖已存在的数据 + * --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 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; +}); + +// 创建 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'); + }); + }); +} + +// 数据脱敏函数 +function sanitizeData(data, type) { + const sanitized = { ...data }; + + switch (type) { + case 'apikey': + // 隐藏 API Key 的大部分内容 + if (sanitized.apiKey) { + sanitized.apiKey = sanitized.apiKey.substring(0, 10) + '...[REDACTED]'; + } + break; + + case 'claude_account': + case 'gemini_account': + // 隐藏 OAuth tokens + 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 'admin': + // 隐藏管理员密码 + if (sanitized.password) { + sanitized.password = '[REDACTED]'; + } + break; + } + + 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'}`); + + // 连接 Redis + await redis.connect(); + logger.success('✅ Connected to Redis'); + + const exportData = { + metadata: { + version: '1.0', + exportDate: new Date().toISOString(), + sanitized: shouldSanitize, + 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 = []; + + for (const key of keys) { + if (key === 'apikey:hash_map') continue; + + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data); + } + } + + exportData.data.apiKeys = apiKeys; + logger.success(`✅ Exported ${apiKeys.length} API Keys`); + } + + // 导出 Claude 账户 + if (types.includes('all') || types.includes('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 = []; + + for (const key of keys) { + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + 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); + } catch (e) { + // 保持原样 + } + } + accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data); + } + } + + exportData.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 = []; + + for (const key of geminiKeys) { + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data); + } + } + + exportData.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 = []; + + for (const key of keys) { + if (key.includes('admin_username:')) continue; + + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data); + } + } + + exportData.data.admins = admins; + logger.success(`✅ Exported ${admins.length} admins`); + } + + // 写入文件 + 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}`); + } + if (exportData.data.claudeAccounts) { + console.log(`Claude Accounts: ${exportData.data.claudeAccounts.length}`); + } + if (exportData.data.geminiAccounts) { + console.log(`Gemini Accounts: ${exportData.data.geminiAccounts.length}`); + } + if (exportData.data.admins) { + console.log(`Admins: ${exportData.data.admins.length}`); + } + console.log('='.repeat(60)); + + if (shouldSanitize) { + logger.warn('⚠️ Sensitive data has been sanitized in this export.'); + } + + } catch (error) { + logger.error('💥 Export failed:', error); + process.exit(1); + } finally { + await redis.disconnect(); + rl.close(); + } +} + +// 导入数据 +async function importData() { + try { + const inputFile = params.input; + if (!inputFile) { + 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 fileContent = await fs.readFile(inputFile, 'utf8'); + const importData = JSON.parse(fileContent); + + // 验证文件格式 + if (!importData.metadata || !importData.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?'); + if (!proceed) { + 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}`); + } + if (importData.data.claudeAccounts) { + console.log(`Claude Accounts to import: ${importData.data.claudeAccounts.length}`); + } + if (importData.data.geminiAccounts) { + console.log(`Gemini Accounts to import: ${importData.data.geminiAccounts.length}`); + } + if (importData.data.admins) { + console.log(`Admins to import: ${importData.data.admins.length}`); + } + console.log('='.repeat(60) + '\n'); + + // 确认导入 + const confirmed = await askConfirmation('⚠️ Proceed with import?'); + if (!confirmed) { + logger.info('❌ Import cancelled'); + return; + } + + // 连接 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) { + try { + 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; + } else { + const overwrite = await askConfirmation(`API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?`); + if (!overwrite) { + stats.skipped++; + continue; + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline(); + for (const [field, value] of Object.entries(apiKey)) { + pipeline.hset(`apikey:${apiKey.id}`, field, value); + } + await pipeline.exec(); + + // 更新哈希映射 + if (apiKey.apiKey && !importData.metadata.sanitized) { + await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id); + } + + 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++; + } + } + } + + // 导入 Claude 账户 + if (importData.data.claudeAccounts) { + logger.info('\n📥 Importing Claude accounts...'); + for (const account of importData.data.claudeAccounts) { + try { + 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; + } else { + const overwrite = await askConfirmation(`Claude account "${account.name}" (${account.id}) exists. Overwrite?`); + if (!overwrite) { + stats.skipped++; + continue; + } + } + } + + // 使用 hset 存储到哈希表 + 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)); + } else { + pipeline.hset(`claude_account:${account.id}`, field, value); + } + } + 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++; + } + } + } + + // 导入 Gemini 账户 + if (importData.data.geminiAccounts) { + logger.info('\n📥 Importing Gemini accounts...'); + for (const account of importData.data.geminiAccounts) { + try { + 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; + } else { + const overwrite = await askConfirmation(`Gemini account "${account.name}" (${account.id}) exists. Overwrite?`); + if (!overwrite) { + stats.skipped++; + continue; + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline(); + for (const [field, value] of Object.entries(account)) { + pipeline.hset(`gemini_account:${account.id}`, field, value); + } + 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++; + } + } + } + + // 显示导入结果 + 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); + } finally { + await redis.disconnect(); + rl.close(); + } +} + +// 显示帮助信息 +function showHelp() { + console.log(` +Data Transfer Tool for Claude Relay Service + +This tool allows you to export and import data between environments. + +Usage: + node scripts/data-transfer.js [options] + +Commands: + export Export data from Redis to a JSON file + import Import data from a JSON file to Redis + +Export Options: + --output=FILE Output filename (default: backup-YYYY-MM-DD.json) + --types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all) + --sanitize Remove sensitive data from export + +Import Options: + --input=FILE Input filename (required) + --force Overwrite existing data without asking + --skip-conflicts Skip conflicting data without asking + +Examples: + # Export all data + node scripts/data-transfer.js export + + # Export only API keys with sanitized data + node scripts/data-transfer.js export --types=apikeys --sanitize + + # Import data, skip conflicts + node scripts/data-transfer.js import --input=backup.json --skip-conflicts + + # 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); + } + + switch (command) { + case 'export': + await exportData(); + break; + + case 'import': + await importData(); + break; + + default: + 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 diff --git a/scripts/debug-redis-keys.js b/scripts/debug-redis-keys.js new file mode 100644 index 00000000..22b25050 --- /dev/null +++ b/scripts/debug-redis-keys.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +/** + * Redis 键调试工具 + * 用于查看 Redis 中存储的所有键和数据结构 + */ + +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'); + + // 获取所有键 + const allKeys = await redis.client.keys('*'); + logger.info(`\n📊 Total keys in Redis: ${allKeys.length}\n`); + + // 按类型分组 + const keysByType = { + apiKeys: [], + claudeAccounts: [], + geminiAccounts: [], + admins: [], + sessions: [], + usage: [], + other: [] + }; + + // 分类键 + for (const key of allKeys) { + if (key.startsWith('apikey:')) { + keysByType.apiKeys.push(key); + } else if (key.startsWith('claude_account:')) { + keysByType.claudeAccounts.push(key); + } else if (key.startsWith('gemini_account:')) { + keysByType.geminiAccounts.push(key); + } else if (key.startsWith('admin:') || key.startsWith('admin_username:')) { + 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); + } else { + 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)); + + // 详细显示每个类别的键 + if (keysByType.apiKeys.length > 0) { + console.log('\n🔑 API Keys:'); + for (const key of keysByType.apiKeys.slice(0, 5)) { + console.log(` - ${key}`); + } + if (keysByType.apiKeys.length > 5) { + console.log(` ... and ${keysByType.apiKeys.length - 5} more`); + } + } + + if (keysByType.claudeAccounts.length > 0) { + console.log('\n🤖 Claude Accounts:'); + for (const key of keysByType.claudeAccounts) { + console.log(` - ${key}`); + } + } + + if (keysByType.geminiAccounts.length > 0) { + console.log('\n💎 Gemini Accounts:'); + for (const key of keysByType.geminiAccounts) { + console.log(` - ${key}`); + } + } + + if (keysByType.other.length > 0) { + console.log('\n❓ Other Keys:'); + for (const key of keysByType.other.slice(0, 10)) { + console.log(` - ${key}`); + } + if (keysByType.other.length > 10) { + console.log(` ... and ${keysByType.other.length - 10} more`); + } + } + + // 检查数据类型 + console.log('\n' + '='.repeat(60)); + console.log('🔍 Checking Data Types:'); + console.log('='.repeat(60)); + + // 随机检查几个键的类型 + 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}`); + } + + } catch (error) { + logger.error('💥 Debug failed:', error); + } finally { + 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 diff --git a/scripts/fix-inquirer.js b/scripts/fix-inquirer.js new file mode 100644 index 00000000..9afa8d5f --- /dev/null +++ b/scripts/fix-inquirer.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +/** + * 修复 inquirer ESM 问题 + * 降级到支持 CommonJS 的版本 + */ + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +console.log('🔧 修复 inquirer ESM 兼容性问题...\n'); + +try { + // 卸载当前版本 + 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'); + +} catch (error) { + console.error('❌ 修复失败:', error.message); + process.exit(1); +} \ No newline at end of file diff --git a/scripts/fix-usage-stats.js b/scripts/fix-usage-stats.js new file mode 100644 index 00000000..b725cc4a --- /dev/null +++ b/scripts/fix-usage-stats.js @@ -0,0 +1,227 @@ +#!/usr/bin/env node + +/** + * 数据迁移脚本:修复历史使用统计数据 + * + * 功能: + * 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'); + +// 解析命令行参数 +const args = process.argv.slice(2); +const isDryRun = args.includes('--dry-run'); + +async function fixUsageStats() { + try { + logger.info('🔧 开始修复使用统计数据...'); + if (isDryRun) { + logger.info('📝 DRY RUN 模式 - 不会实际修改数据'); + } + + // 连接到 Redis + await redis.connect(); + logger.success('✅ 已连接到 Redis'); + + const client = redis.getClientSafe(); + + // 统计信息 + let 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; + + for (const apiKeyKey of apiKeys) { + const keyId = apiKeyKey.replace('apikey:', ''); + const usageKey = `usage:${keyId}`; + + try { + 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; + + // 计算正确的 allTokens + const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + logger.info(` 修复 ${keyId}: ${currentAllTokens} -> ${correctAllTokens}`); + + if (!isDryRun) { + await client.hset(usageKey, 'totalAllTokens', correctAllTokens); + } + stats.fixedTotalKeys++; + } + } + } catch (error) { + logger.error(` 错误处理 ${keyId}: ${error.message}`); + stats.errors++; + } + } + + // 2. 修复每日统计数据 + 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); + 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; + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + if (!isDryRun) { + await client.hset(dailyKey, 'allTokens', correctAllTokens); + } + stats.fixedDailyKeys++; + } + } + } catch (error) { + logger.error(` 错误处理 ${dailyKey}: ${error.message}`); + stats.errors++; + } + } + + // 3. 修复每月统计数据 + 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); + 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; + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + if (!isDryRun) { + await client.hset(monthlyKey, 'allTokens', correctAllTokens); + } + stats.fixedMonthlyKeys++; + } + } + } catch (error) { + logger.error(` 错误处理 ${monthlyKey}: ${error.message}`); + stats.errors++; + } + } + + // 4. 修复模型级别的统计数据 + 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); + + for (const modelKey of modelKeys) { + try { + 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; + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + if (!isDryRun) { + await client.hset(modelKey, 'allTokens', correctAllTokens); + } + stats.fixedModelKeys++; + } + } + } catch (error) { + logger.error(` 错误处理 ${modelKey}: ${error.message}`); + stats.errors++; + } + } + } + + // 5. 验证修复结果 + if (!isDryRun) { + logger.info('\n✅ 验证修复结果...'); + + // 随机抽样验证 + 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 ? '✅' : '❌'}`); + } + } + + // 打印统计结果 + 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 参数来实际执行修复'); + } else { + logger.success('\n✅ 数据修复完成!'); + } + + } catch (error) { + logger.error('❌ 修复过程出错:', error); + process.exit(1); + } finally { + await redis.disconnect(); + } +} + +// 执行修复 +fixUsageStats().catch(error => { + logger.error('❌ 未处理的错误:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/migrate-apikey-expiry.js b/scripts/migrate-apikey-expiry.js new file mode 100644 index 00000000..a7d85fc2 --- /dev/null +++ b/scripts/migrate-apikey-expiry.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +/** + * 数据迁移脚本:为现有 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 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; + +// 创建 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'); + }); + }); +} + +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'}`); + + // 连接 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 stats = { + total: apiKeys.length, + needsMigration: 0, + alreadyHasExpiry: 0, + migrated: 0, + errors: 0 + }; + + // 需要迁移的 Keys + 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`); + } else { + 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; + } + + // 显示迁移摘要 + 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; + } + } + + // 计算新的过期时间 + 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})`); + } else { + logger.info(`[DRY RUN] Would migrate: "${key.name}" (${key.id})`); + } + stats.migrated++; + } catch (error) { + 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'); + + 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.'); + } + + } catch (error) { + logger.error('💥 Migration failed:', error); + process.exit(1); + } finally { + // 清理 + rl.close(); + await redis.disconnect(); + logger.info('👋 Disconnected from Redis'); + } +} + +// 显示帮助信息 +if (params.help) { + console.log(` +API Key Expiry Migration Script + +This script adds expiry dates to existing API Keys that don't have one. + +Usage: + node scripts/migrate-apikey-expiry.js [options] + +Options: + --days=NUMBER Set default expiry days (default: 30) + --dry-run Simulate the migration without making changes + --help Show this help message + +Examples: + # Set 30-day expiry for all API Keys without expiry + node scripts/migrate-apikey-expiry.js + + # Set 90-day expiry + node scripts/migrate-apikey-expiry.js --days=90 + + # Test run without making changes + node scripts/migrate-apikey-expiry.js --dry-run +`); + process.exit(0); +} + +// 运行迁移 +migrateApiKeys().catch(error => { + logger.error('💥 Unexpected error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/test-apikey-expiry.js b/scripts/test-apikey-expiry.js new file mode 100644 index 00000000..a799e8d5 --- /dev/null +++ b/scripts/test-apikey-expiry.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +/** + * 测试 API Key 过期功能 + * 快速创建和修改 API Key 过期时间以便测试 + */ + +const apiKeyService = require('../src/services/apiKeyService'); +const redis = require('../src/models/redis'); +const logger = require('../src/utils/logger'); +const chalk = require('chalk'); + +async function createTestApiKeys() { + console.log(chalk.bold.blue('\n🧪 创建测试 API Keys\n')); + + try { + await redis.connect(); + + // 创建不同过期时间的测试 Keys + const testKeys = [ + { + name: 'Test-Expired', + description: '已过期的测试 Key', + expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() // 1天前过期 + }, + { + name: 'Test-1Hour', + description: '1小时后过期的测试 Key', + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() // 1小时后 + }, + { + name: 'Test-1Day', + description: '1天后过期的测试 Key', + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // 1天后 + }, + { + name: 'Test-7Days', + description: '7天后过期的测试 Key', + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7天后 + }, + { + name: 'Test-Never', + description: '永不过期的测试 Key', + expiresAt: null // 永不过期 + } + ]; + + console.log('正在创建测试 API Keys...\n'); + + for (const keyData of testKeys) { + try { + const newKey = await apiKeyService.generateApiKey(keyData); + + const expiryInfo = keyData.expiresAt + ? new Date(keyData.expiresAt).toLocaleString() + : '永不过期'; + + console.log(`✅ 创建成功: ${keyData.name}`); + console.log(` API Key: ${newKey.apiKey}`); + console.log(` 过期时间: ${expiryInfo}`); + console.log(''); + + } catch (error) { + console.log(chalk.red(`❌ 创建失败: ${keyData.name} - ${error.message}`)); + } + } + + // 运行清理任务测试 + console.log(chalk.bold.yellow('\n🔄 运行清理任务...\n')); + const cleanedCount = await apiKeyService.cleanupExpiredKeys(); + console.log(`清理了 ${cleanedCount} 个过期的 API Keys\n`); + + // 显示所有 API Keys 状态 + console.log(chalk.bold.cyan('📊 当前所有 API Keys 状态:\n')); + const allKeys = await apiKeyService.getAllApiKeys(); + + for (const key of allKeys) { + const now = new Date(); + const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null; + let status = '✅ 活跃'; + let expiryInfo = '永不过期'; + + if (expiresAt) { + if (expiresAt < now) { + status = '❌ 已过期'; + expiryInfo = `过期于 ${expiresAt.toLocaleString()}`; + } else { + const hoursLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60)); + const daysLeft = Math.ceil(hoursLeft / 24); + + if (hoursLeft < 24) { + expiryInfo = chalk.yellow(`${hoursLeft}小时后过期`); + } else if (daysLeft <= 7) { + expiryInfo = chalk.yellow(`${daysLeft}天后过期`); + } else { + expiryInfo = chalk.green(`${daysLeft}天后过期`); + } + } + } + + if (!key.isActive) { + status = '🔒 已禁用'; + } + + console.log(`${status} ${key.name} - ${expiryInfo}`); + console.log(` API Key: ${key.apiKey?.substring(0, 30)}...`); + console.log(''); + } + + } catch (error) { + console.error(chalk.red('测试失败:'), error); + } finally { + await redis.disconnect(); + } +} + +// 主函数 +async function main() { + console.log(chalk.bold.magenta('\n====================================')); + console.log(chalk.bold.magenta(' API Key 过期功能测试工具')); + console.log(chalk.bold.magenta('====================================\n')); + + console.log('此工具将:'); + console.log('1. 创建不同过期时间的测试 API Keys'); + console.log('2. 运行清理任务禁用过期的 Keys'); + console.log('3. 显示所有 Keys 的当前状态\n'); + + console.log(chalk.yellow('⚠️ 注意:这会在您的系统中创建真实的 API Keys\n')); + + const readline = require('readline').createInterface({ + input: process.stdin, + output: process.stdout + }); + + readline.question('是否继续?(y/n): ', async (answer) => { + if (answer.toLowerCase() === 'y') { + await createTestApiKeys(); + + console.log(chalk.bold.green('\n✅ 测试完成!\n')); + console.log('您现在可以:'); + console.log('1. 使用 CLI 工具管理这些测试 Keys:'); + console.log(' npm run cli keys'); + console.log(''); + console.log('2. 在 Web 界面查看和管理这些 Keys'); + console.log(''); + console.log('3. 测试 API 调用时的过期验证'); + } else { + console.log('\n已取消'); + } + + readline.close(); + }); +} + +// 运行 +main().catch(error => { + console.error(chalk.red('错误:'), error); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/test-import-encryption.js b/scripts/test-import-encryption.js new file mode 100644 index 00000000..40cd5b3b --- /dev/null +++ b/scripts/test-import-encryption.js @@ -0,0 +1,181 @@ +#!/usr/bin/env node + +/** + * 测试导入加密处理 + * 验证增强版数据传输工具是否正确处理加密和未加密的导出数据 + */ + +const fs = require('fs').promises; +const path = require('path'); +const crypto = require('crypto'); +const config = require('../config/config'); +const logger = require('../src/utils/logger'); + +// 模拟加密函数 +function encryptData(data, salt = 'salt') { + 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 decryptData(encryptedData, salt = 'salt') { + 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; + } + return encryptedData; + } catch (error) { + logger.warn(`⚠️ Failed to decrypt data: ${error.message}`); + return encryptedData; + } +} + +async function testImportHandling() { + console.log('🧪 测试导入加密处理\n'); + + // 测试数据 + const testClaudeAccount = { + id: 'test-claude-123', + name: 'Test Claude Account', + email: 'test@example.com', + password: 'testPassword123', + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + claudeAiOauth: { + access_token: 'oauth-access-token', + refresh_token: 'oauth-refresh-token', + scopes: ['read', 'write'] + } + }; + + const testGeminiAccount = { + id: 'test-gemini-456', + name: 'Test Gemini Account', + geminiOauth: { + access_token: 'gemini-access-token', + refresh_token: 'gemini-refresh-token' + }, + accessToken: 'gemini-access-token', + refreshToken: 'gemini-refresh-token' + }; + + // 1. 创建解密的导出文件(模拟 --decrypt=true) + const decryptedExport = { + metadata: { + version: '2.0', + exportDate: new Date().toISOString(), + sanitized: false, + decrypted: true, // 标记为已解密 + types: ['all'] + }, + data: { + claudeAccounts: [testClaudeAccount], + geminiAccounts: [testGeminiAccount] + } + }; + + // 2. 创建加密的导出文件(模拟 --decrypt=false) + const encryptedClaudeAccount = { ...testClaudeAccount }; + encryptedClaudeAccount.email = encryptData(encryptedClaudeAccount.email); + encryptedClaudeAccount.password = encryptData(encryptedClaudeAccount.password); + encryptedClaudeAccount.accessToken = encryptData(encryptedClaudeAccount.accessToken); + encryptedClaudeAccount.refreshToken = encryptData(encryptedClaudeAccount.refreshToken); + encryptedClaudeAccount.claudeAiOauth = encryptData(JSON.stringify(encryptedClaudeAccount.claudeAiOauth)); + + const encryptedGeminiAccount = { ...testGeminiAccount }; + encryptedGeminiAccount.geminiOauth = encryptData(JSON.stringify(encryptedGeminiAccount.geminiOauth), 'gemini-account-salt'); + encryptedGeminiAccount.accessToken = encryptData(encryptedGeminiAccount.accessToken, 'gemini-account-salt'); + encryptedGeminiAccount.refreshToken = encryptData(encryptedGeminiAccount.refreshToken, 'gemini-account-salt'); + + const encryptedExport = { + metadata: { + version: '2.0', + exportDate: new Date().toISOString(), + sanitized: false, + decrypted: false, // 标记为未解密(加密状态) + types: ['all'] + }, + data: { + claudeAccounts: [encryptedClaudeAccount], + geminiAccounts: [encryptedGeminiAccount] + } + }; + + // 写入测试文件 + const testDir = path.join(__dirname, '../data/test-imports'); + await fs.mkdir(testDir, { recursive: true }); + + await fs.writeFile( + path.join(testDir, 'decrypted-export.json'), + JSON.stringify(decryptedExport, null, 2) + ); + + await fs.writeFile( + path.join(testDir, 'encrypted-export.json'), + JSON.stringify(encryptedExport, null, 2) + ); + + console.log('✅ 测试文件已创建:'); + console.log(' - data/test-imports/decrypted-export.json (解密的数据)'); + console.log(' - data/test-imports/encrypted-export.json (加密的数据)\n'); + + console.log('📋 测试场景:\n'); + + console.log('1. 导入解密的数据(decrypted=true):'); + console.log(' - 导入时应该重新加密敏感字段'); + console.log(' - 命令: npm run data:import:enhanced -- --input=data/test-imports/decrypted-export.json\n'); + + console.log('2. 导入加密的数据(decrypted=false):'); + console.log(' - 导入时应该保持原样(已经是加密的)'); + console.log(' - 命令: npm run data:import:enhanced -- --input=data/test-imports/encrypted-export.json\n'); + + console.log('3. 验证导入后的数据:'); + console.log(' - 使用 CLI 查看账户状态'); + console.log(' - 命令: npm run cli accounts list\n'); + + // 显示示例数据对比 + console.log('📊 数据对比示例:\n'); + console.log('原始数据(解密状态):'); + console.log(` email: "${testClaudeAccount.email}"`); + console.log(` password: "${testClaudeAccount.password}"`); + console.log(` accessToken: "${testClaudeAccount.accessToken}"\n`); + + console.log('加密后的数据:'); + console.log(` email: "${encryptedClaudeAccount.email.substring(0, 50)}..."`); + console.log(` password: "${encryptedClaudeAccount.password.substring(0, 50)}..."`); + console.log(` accessToken: "${encryptedClaudeAccount.accessToken.substring(0, 50)}..."\n`); + + // 验证加密/解密 + console.log('🔐 验证加密/解密功能:'); + const testString = 'test-data-123'; + const encrypted = encryptData(testString); + const decrypted = decryptData(encrypted); + console.log(` 原始: "${testString}"`); + console.log(` 加密: "${encrypted.substring(0, 50)}..."`); + console.log(` 解密: "${decrypted}"`); + console.log(` 验证: ${testString === decrypted ? '✅ 成功' : '❌ 失败'}\n`); +} + +// 运行测试 +testImportHandling().catch(error => { + console.error('❌ 测试失败:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/models/redis.js b/src/models/redis.js index 45ce4fbb..f2664f0e 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -324,11 +324,13 @@ class RedisClient { const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0; const totalFromSeparate = inputTokens + outputTokens; + // 计算实际的总tokens(包含所有类型) + const actualAllTokens = allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens); if (totalFromSeparate === 0 && tokens > 0) { // 旧数据:没有输入输出分离 return { - tokens, + tokens: tokens, // 保持兼容性,但统一使用allTokens inputTokens: Math.round(tokens * 0.3), // 假设30%为输入 outputTokens: Math.round(tokens * 0.7), // 假设70%为输出 cacheCreateTokens: 0, // 旧数据没有缓存token @@ -337,14 +339,14 @@ class RedisClient { requests }; } else { - // 新数据或无数据 + // 新数据或无数据 - 统一使用allTokens作为tokens的值 return { - tokens, + tokens: actualAllTokens, // 统一使用allTokens作为总数 inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, - allTokens: allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens), // 计算或使用存储的值 + allTokens: actualAllTokens, requests }; } diff --git a/src/routes/admin.js b/src/routes/admin.js index a8a2fbb9..5ffe9845 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -21,6 +21,77 @@ const router = express.Router(); router.get('/api-keys', authenticateAdmin, async (req, res) => { try { const apiKeys = await apiKeyService.getAllApiKeys(); + + // 为每个API Key计算准确的费用 + for (const apiKey of apiKeys) { + if (apiKey.usage && apiKey.usage.total) { + const client = redis.getClientSafe(); + + // 使用与展开模型统计相同的数据源 + // 获取所有时间的模型统计数据 + 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.inputTokens) || 0; + stats.outputTokens += parseInt(data.outputTokens) || 0; + stats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0; + stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0; + } + } + + let totalCost = 0; + + // 计算每个模型的费用 + for (const [model, stats] of modelStatsMap) { + const usage = { + input_tokens: stats.inputTokens, + output_tokens: stats.outputTokens, + cache_creation_input_tokens: stats.cacheCreateTokens, + cache_read_input_tokens: stats.cacheReadTokens + }; + + const costResult = CostCalculator.calculateCost(usage, model); + totalCost += costResult.costs.total; + } + + // 如果没有详细的模型数据,使用总量数据和默认模型计算 + if (modelStatsMap.size === 0) { + const usage = { + input_tokens: apiKey.usage.total.inputTokens || 0, + output_tokens: apiKey.usage.total.outputTokens || 0, + cache_creation_input_tokens: apiKey.usage.total.cacheCreateTokens || 0, + cache_read_input_tokens: apiKey.usage.total.cacheReadTokens || 0 + }; + + const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022'); + totalCost = costResult.costs.total; + } + + // 添加格式化的费用到响应数据 + apiKey.usage.total.cost = totalCost; + apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost); + } + } + res.json({ success: true, data: apiKeys }); } catch (error) { logger.error('❌ Failed to get API keys:', error); @@ -112,7 +183,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params; - const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels } = req.body; + const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, expiresAt } = req.body; // 只允许更新指定字段 const updates = {}; @@ -178,6 +249,21 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.restrictedModels = restrictedModels; } + // 处理过期时间字段 + if (expiresAt !== undefined) { + if (expiresAt === null) { + // null 表示永不过期 + updates.expiresAt = null; + } else { + // 验证日期格式 + const expireDate = new Date(expiresAt); + if (isNaN(expireDate.getTime())) { + return res.status(400).json({ error: 'Invalid expiration date format' }); + } + updates.expiresAt = expiresAt; + } + } + await apiKeyService.updateApiKey(keyId, updates); logger.success(`📝 Admin updated API key: ${keyId}`); @@ -582,8 +668,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { redis.getSystemAverages() ]); - // 计算使用统计(包含cache tokens) - const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0); + // 计算使用统计(统一使用allTokens) + const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.allTokens || 0), 0); const totalRequestsUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0); const totalInputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.inputTokens || 0), 0); const totalOutputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.outputTokens || 0), 0); diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index c9527767..718de0fc 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -283,14 +283,17 @@ class ApiKeyService { let cleanedCount = 0; for (const key of apiKeys) { - if (key.expiresAt && new Date(key.expiresAt) < now) { - await redis.deleteApiKey(key.id); + // 检查是否已过期且仍处于激活状态 + if (key.expiresAt && new Date(key.expiresAt) < now && key.isActive === 'true') { + // 将过期的 API Key 标记为禁用状态,而不是直接删除 + await this.updateApiKey(key.id, { isActive: false }); + logger.info(`🔒 API Key ${key.id} (${key.name}) has expired and been disabled`); cleanedCount++; } } if (cleanedCount > 0) { - logger.success(`🧹 Cleaned up ${cleanedCount} expired API keys`); + logger.success(`🧹 Disabled ${cleanedCount} expired API keys`); } return cleanedCount; diff --git a/web/admin/app.js b/web/admin/app.js index 990afadb..9ccc7039 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -125,7 +125,10 @@ const app = createApp({ permissions: 'all', // 'claude', 'gemini', 'all' enableModelRestriction: false, restrictedModels: [], - modelInput: '' + modelInput: '', + expireDuration: '', // 过期时长选择 + customExpireDate: '', // 自定义过期日期 + expiresAt: null // 实际的过期时间戳 }, apiKeyModelStats: {}, // 存储每个key的模型统计数据 expandedApiKeys: {}, // 跟踪展开的API Keys @@ -154,6 +157,18 @@ const app = createApp({ description: '', showFullKey: false }, + + // API Key续期 + showRenewApiKeyModal: false, + renewApiKeyLoading: false, + renewApiKeyForm: { + id: '', + name: '', + currentExpiresAt: null, + renewDuration: '30d', + customExpireDate: '', + newExpiresAt: null + }, // 编辑API Key showEditApiKeyModal: false, @@ -284,6 +299,13 @@ const app = createApp({ return this.accounts.filter(account => account.accountType === 'dedicated' && account.isActive === true ); + }, + + // 计算最小日期时间(当前时间) + minDateTime() { + const now = new Date(); + now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); + return now.toISOString().slice(0, 16); } }, @@ -527,6 +549,72 @@ const app = createApp({ }); }, + // 更新过期时间 + updateExpireAt() { + const duration = this.apiKeyForm.expireDuration; + if (!duration) { + this.apiKeyForm.expiresAt = null; + return; + } + + if (duration === 'custom') { + // 自定义日期需要用户选择 + return; + } + + const now = new Date(); + const durationMap = { + '1d': 1, + '7d': 7, + '30d': 30, + '90d': 90, + '180d': 180, + '365d': 365 + }; + + const days = durationMap[duration]; + if (days) { + const expireDate = new Date(now.getTime() + days * 24 * 60 * 60 * 1000); + this.apiKeyForm.expiresAt = expireDate.toISOString(); + } + }, + + // 更新自定义过期时间 + updateCustomExpireAt() { + if (this.apiKeyForm.customExpireDate) { + const expireDate = new Date(this.apiKeyForm.customExpireDate); + this.apiKeyForm.expiresAt = expireDate.toISOString(); + } + }, + + // 格式化过期日期 + formatExpireDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + }, + + // 检查 API Key 是否已过期 + isApiKeyExpired(expiresAt) { + if (!expiresAt) return false; + return new Date(expiresAt) < new Date(); + }, + + // 检查 API Key 是否即将过期(7天内) + isApiKeyExpiringSoon(expiresAt) { + if (!expiresAt) return false; + const expireDate = new Date(expiresAt); + const now = new Date(); + const daysUntilExpire = (expireDate - now) / (1000 * 60 * 60 * 24); + return daysUntilExpire > 0 && daysUntilExpire <= 7; + }, + // 打开创建账户模态框 openCreateAccountModal() { console.log('Opening Account modal...'); @@ -1784,7 +1872,8 @@ const app = createApp({ geminiAccountId: this.apiKeyForm.geminiAccountId || null, permissions: this.apiKeyForm.permissions || 'all', enableModelRestriction: this.apiKeyForm.enableModelRestriction, - restrictedModels: this.apiKeyForm.restrictedModels + restrictedModels: this.apiKeyForm.restrictedModels, + expiresAt: this.apiKeyForm.expiresAt }) }); @@ -1805,7 +1894,23 @@ const app = createApp({ // 关闭创建弹窗并清理表单 this.showCreateApiKeyModal = false; - this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '', rateLimitWindow: '', rateLimitRequests: '', claudeAccountId: '', enableModelRestriction: false, restrictedModels: [], modelInput: '' }; + this.apiKeyForm = { + name: '', + tokenLimit: '', + description: '', + concurrencyLimit: '', + rateLimitWindow: '', + rateLimitRequests: '', + claudeAccountId: '', + geminiAccountId: '', + permissions: 'all', + enableModelRestriction: false, + restrictedModels: [], + modelInput: '', + expireDuration: '', + customExpireDate: '', + expiresAt: null + }; // 重新加载API Keys列表 await this.loadApiKeys(); @@ -1851,6 +1956,111 @@ const app = createApp({ } }, + // 打开续期弹窗 + openRenewApiKeyModal(key) { + this.renewApiKeyForm = { + id: key.id, + name: key.name, + currentExpiresAt: key.expiresAt, + renewDuration: '30d', + customExpireDate: '', + newExpiresAt: null + }; + this.showRenewApiKeyModal = true; + // 立即计算新的过期时间 + this.updateRenewExpireAt(); + }, + + // 关闭续期弹窗 + closeRenewApiKeyModal() { + this.showRenewApiKeyModal = false; + this.renewApiKeyForm = { + id: '', + name: '', + currentExpiresAt: null, + renewDuration: '30d', + customExpireDate: '', + newExpiresAt: null + }; + }, + + // 更新续期过期时间 + updateRenewExpireAt() { + const duration = this.renewApiKeyForm.renewDuration; + + if (duration === 'permanent') { + this.renewApiKeyForm.newExpiresAt = null; + return; + } + + if (duration === 'custom') { + // 自定义日期需要用户选择 + return; + } + + // 计算新的过期时间 + const baseTime = this.renewApiKeyForm.currentExpiresAt + ? new Date(this.renewApiKeyForm.currentExpiresAt) + : new Date(); + + // 如果当前已过期,从现在开始计算 + if (baseTime < new Date()) { + baseTime.setTime(new Date().getTime()); + } + + const durationMap = { + '7d': 7, + '30d': 30, + '90d': 90, + '180d': 180, + '365d': 365 + }; + + const days = durationMap[duration]; + if (days) { + const expireDate = new Date(baseTime.getTime() + days * 24 * 60 * 60 * 1000); + this.renewApiKeyForm.newExpiresAt = expireDate.toISOString(); + } + }, + + // 更新自定义续期时间 + updateCustomRenewExpireAt() { + if (this.renewApiKeyForm.customExpireDate) { + const expireDate = new Date(this.renewApiKeyForm.customExpireDate); + this.renewApiKeyForm.newExpiresAt = expireDate.toISOString(); + } + }, + + // 执行续期操作 + async renewApiKey() { + this.renewApiKeyLoading = true; + try { + const data = await this.apiRequest('/admin/api-keys/' + this.renewApiKeyForm.id, { + method: 'PUT', + body: JSON.stringify({ + expiresAt: this.renewApiKeyForm.newExpiresAt + }) + }); + + if (!data) { + return; + } + + if (data.success) { + this.showToast('API Key 续期成功', 'success'); + this.closeRenewApiKeyModal(); + await this.loadApiKeys(); + } else { + this.showToast(data.message || '续期失败', 'error'); + } + } catch (error) { + console.error('Error renewing API key:', error); + this.showToast('续期失败,请检查网络连接', 'error'); + } finally { + this.renewApiKeyLoading = false; + } + }, + openEditApiKeyModal(key) { this.editApiKeyForm = { id: key.id, @@ -2889,23 +3099,13 @@ const app = createApp({ calculateApiKeyCost(usage) { if (!usage || !usage.total) return '$0.000000'; - // 使用通用模型价格估算 - const totalInputTokens = usage.total.inputTokens || 0; - const totalOutputTokens = usage.total.outputTokens || 0; - const totalCacheCreateTokens = usage.total.cacheCreateTokens || 0; - const totalCacheReadTokens = usage.total.cacheReadTokens || 0; + // 使用后端返回的准确费用数据 + if (usage.total.formattedCost) { + return usage.total.formattedCost; + } - // 简单估算(使用Claude 3.5 Sonnet价格) - const inputCost = (totalInputTokens / 1000000) * 3.00; - const outputCost = (totalOutputTokens / 1000000) * 15.00; - const cacheCreateCost = (totalCacheCreateTokens / 1000000) * 3.75; - const cacheReadCost = (totalCacheReadTokens / 1000000) * 0.30; - - const totalCost = inputCost + outputCost + cacheCreateCost + cacheReadCost; - - if (totalCost < 0.000001) return '$0.000000'; - if (totalCost < 0.01) return '$' + totalCost.toFixed(6); - return '$' + totalCost.toFixed(4); + // 如果没有后端费用数据,返回默认值 + return '$0.000000'; }, // 初始化日期筛选器 diff --git a/web/admin/index.html b/web/admin/index.html index f73f12d9..faf49691 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -568,6 +568,7 @@ 状态 使用统计 创建时间 + 过期时间 操作 @@ -654,6 +655,11 @@ 输入: {{ formatNumber((key.usage && key.usage.total && key.usage.total.inputTokens) || 0) }} 输出: {{ formatNumber((key.usage && key.usage.total && key.usage.total.outputTokens) || 0) }} + +
+ 缓存创建: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) }} + 缓存读取: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) }} +
RPM: {{ (key.usage && key.usage.averages && key.usage.averages.rpm) || 0 }} @@ -678,6 +684,25 @@ {{ new Date(key.createdAt).toLocaleDateString() }} + +
+
+ + 已过期 +
+
+ + {{ formatExpireDate(key.expiresAt) }} +
+
+ {{ formatExpireDate(key.expiresAt) }} +
+
+
+ + 永不过期 +
+
+
+
+ + +
+ +
+

+ 将于 {{ formatExpireDate(apiKeyForm.expiresAt) }} 过期 +

+
+
@@ -2410,6 +2472,92 @@
+ + +