Merge pull request #67 from kevinconan/main

修复API统计问题,并新增API过期,续期能力
This commit is contained in:
Wesley Liddick
2025-07-25 12:55:07 +08:00
committed by GitHub
20 changed files with 4350 additions and 98 deletions

View File

@@ -4,7 +4,7 @@ const { Command } = require('commander');
const inquirer = require('inquirer'); const inquirer = require('inquirer');
const chalk = require('chalk'); const chalk = require('chalk');
const ora = require('ora'); const ora = require('ora');
const Table = require('table').table; const { table } = require('table');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs'); 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 program
.command('status') .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);
// 筛选即将过期的 Keys7天内
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 Keys7天内'));
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() { async function listClaudeAccounts() {
const spinner = ora('正在获取 Claude 账户...').start(); 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(styles.title('🚀 Claude Relay Service CLI\n'));
console.log('使用以下命令管理服务:\n'); console.log('使用以下命令管理服务:\n');
console.log(' claude-relay-cli admin - 创建初始管理员账户'); console.log(' claude-relay-cli admin - 创建初始管理员账户');
console.log(' claude-relay-cli keys - API Key 管理(查看/修改过期时间/续期/删除)');
console.log(' claude-relay-cli status - 查看系统状态'); console.log(' claude-relay-cli status - 查看系统状态');
console.log('\n使用 --help 查看详细帮助信息'); console.log('\n使用 --help 查看详细帮助信息');
} }

205
docs/UPGRADE_GUIDE.md Normal file
View File

@@ -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 <previous-version-tag>
# 重新安装依赖
npm install
```
### 3. 恢复数据(如需要)
```bash
# 从备份恢复数据
npm run data:import -- --input=prod-backup-<date>.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`
如需进一步帮助,请提供:
- 错误日志
- 使用的命令
- 系统环境信息

View File

@@ -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
- 修改过期时间立即生效
- 清理任务每小时运行一次

View File

@@ -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. 验证加密/解密功能

131
package-lock.json generated
View File

@@ -20,7 +20,7 @@
"google-auth-library": "^10.1.0", "google-auth-library": "^10.1.0",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.2",
"inquirer": "^9.2.15", "inquirer": "^8.2.6",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"ora": "^5.4.1", "ora": "^5.4.1",
@@ -773,15 +773,6 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "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": { "node_modules/@ioredis/commands": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.2.0.tgz", "resolved": "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.2.0.tgz",
@@ -2097,7 +2088,7 @@
}, },
"node_modules/chardet": { "node_modules/chardet": {
"version": "0.7.0", "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==", "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"license": "MIT" "license": "MIT"
}, },
@@ -2187,12 +2178,12 @@
} }
}, },
"node_modules/cli-width": { "node_modules/cli-width": {
"version": "4.1.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz",
"integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==",
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">= 12" "node": ">= 10"
} }
}, },
"node_modules/cliui": { "node_modules/cliui": {
@@ -3107,7 +3098,7 @@
}, },
"node_modules/external-editor": { "node_modules/external-editor": {
"version": "3.1.0", "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==", "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3211,6 +3202,30 @@
"node": "^12.20 || >= 14.13" "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": { "node_modules/file-entry-cache": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -3888,26 +3903,29 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/inquirer": { "node_modules/inquirer": {
"version": "9.3.7", "version": "8.2.6",
"resolved": "https://registry.npmmirror.com/inquirer/-/inquirer-9.3.7.tgz", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz",
"integrity": "sha512-LJKFHCSeIRq9hanN14IlOtPSTe3lNES7TYDTE2xxdAy1LS5rYphajK1qtwvj3YmQXvvk0U2Vbmcni8P9EIQW9w==", "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@inquirer/figures": "^1.0.3", "ansi-escapes": "^4.2.1",
"ansi-escapes": "^4.3.2", "chalk": "^4.1.1",
"cli-width": "^4.1.0", "cli-cursor": "^3.1.0",
"external-editor": "^3.1.0", "cli-width": "^3.0.0",
"mute-stream": "1.0.0", "external-editor": "^3.0.3",
"figures": "^3.0.0",
"lodash": "^4.17.21",
"mute-stream": "0.0.8",
"ora": "^5.4.1", "ora": "^5.4.1",
"run-async": "^3.0.0", "run-async": "^2.4.0",
"rxjs": "^7.8.1", "rxjs": "^7.5.5",
"string-width": "^4.2.3", "string-width": "^4.1.0",
"strip-ansi": "^6.0.1", "strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0", "through": "^2.3.6",
"yoctocolors-cjs": "^2.1.2" "wrap-ansi": "^6.0.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=12.0.0"
} }
}, },
"node_modules/ioredis": { "node_modules/ioredis": {
@@ -5005,6 +5023,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -5283,13 +5307,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/mute-stream": { "node_modules/mute-stream": {
"version": "1.0.0", "version": "0.0.8",
"resolved": "https://registry.npmmirror.com/mute-stream/-/mute-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
"license": "ISC", "license": "ISC"
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
}, },
"node_modules/natural-compare": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
@@ -5600,7 +5621,7 @@
}, },
"node_modules/os-tmpdir": { "node_modules/os-tmpdir": {
"version": "1.0.2", "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==", "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -6169,9 +6190,9 @@
} }
}, },
"node_modules/run-async": { "node_modules/run-async": {
"version": "3.0.0", "version": "2.4.1",
"resolved": "https://registry.npmmirror.com/run-async/-/run-async-3.0.0.tgz", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
"integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
@@ -6203,7 +6224,7 @@
}, },
"node_modules/rxjs": { "node_modules/rxjs": {
"version": "7.8.2", "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==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -6894,9 +6915,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/tmp": {
"version": "0.0.33", "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==", "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -6956,7 +6983,7 @@
}, },
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "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==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
@@ -7263,7 +7290,7 @@
}, },
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "6.2.0", "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==", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -7354,18 +7381,6 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "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"
}
} }
} }
} }

View File

@@ -26,7 +26,17 @@
"lint": "eslint src/**/*.js", "lint": "eslint src/**/*.js",
"docker:build": "docker build -t claude-relay-service .", "docker:build": "docker build -t claude-relay-service .",
"docker:up": "docker-compose up -d", "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": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",
@@ -40,7 +50,7 @@
"google-auth-library": "^10.1.0", "google-auth-library": "^10.1.0",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.2",
"inquirer": "^9.2.15", "inquirer": "^8.2.6",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"ora": "^5.4.1", "ora": "^5.4.1",

View File

@@ -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 <command> [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);
});

517
scripts/data-transfer.js Normal file
View File

@@ -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 <command> [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);
});

123
scripts/debug-redis-keys.js Normal file
View File

@@ -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);
});

32
scripts/fix-inquirer.js Normal file
View File

@@ -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);
}

227
scripts/fix-usage-stats.js Normal file
View File

@@ -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);
});

284
scripts/generate-test-data.js Executable file
View File

@@ -0,0 +1,284 @@
#!/usr/bin/env node
/**
* 历史数据生成脚本
* 用于测试不同时间范围的Token统计功能
*
* 使用方法:
* node scripts/generate-test-data.js [--clean]
*
* 选项:
* --clean: 清除所有测试数据
*/
const redis = require('../src/models/redis');
const logger = require('../src/utils/logger');
// 解析命令行参数
const args = process.argv.slice(2);
const shouldClean = args.includes('--clean');
// 模拟的模型列表
const models = [
'claude-sonnet-4-20250514',
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'claude-3-opus-20240229'
];
// 生成指定日期的数据
async function generateDataForDate(apiKeyId, date, dayOffset) {
const client = redis.getClientSafe();
const dateStr = date.toISOString().split('T')[0];
const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
// 根据日期偏移量调整数据量(越近的日期数据越多)
const requestCount = Math.max(5, 20 - dayOffset * 2); // 5-20个请求
logger.info(`📊 Generating ${requestCount} requests for ${dateStr}`);
for (let i = 0; i < requestCount; i++) {
// 随机选择模型
const model = models[Math.floor(Math.random() * models.length)];
// 生成随机Token数据
const inputTokens = Math.floor(Math.random() * 2000) + 500; // 500-2500
const outputTokens = Math.floor(Math.random() * 3000) + 1000; // 1000-4000
const cacheCreateTokens = Math.random() > 0.7 ? Math.floor(Math.random() * 1000) : 0; // 30%概率有缓存创建
const cacheReadTokens = Math.random() > 0.5 ? Math.floor(Math.random() * 500) : 0; // 50%概率有缓存读取
const coreTokens = inputTokens + outputTokens;
const allTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
// 更新各种统计键
const totalKey = `usage:${apiKeyId}`;
const dailyKey = `usage:daily:${apiKeyId}:${dateStr}`;
const monthlyKey = `usage:monthly:${apiKeyId}:${month}`;
const modelDailyKey = `usage:model:daily:${model}:${dateStr}`;
const modelMonthlyKey = `usage:model:monthly:${model}:${month}`;
const keyModelDailyKey = `usage:${apiKeyId}:model:daily:${model}:${dateStr}`;
const keyModelMonthlyKey = `usage:${apiKeyId}:model:monthly:${model}:${month}`;
await Promise.all([
// 总计数据
client.hincrby(totalKey, 'totalTokens', coreTokens),
client.hincrby(totalKey, 'totalInputTokens', inputTokens),
client.hincrby(totalKey, 'totalOutputTokens', outputTokens),
client.hincrby(totalKey, 'totalCacheCreateTokens', cacheCreateTokens),
client.hincrby(totalKey, 'totalCacheReadTokens', cacheReadTokens),
client.hincrby(totalKey, 'totalAllTokens', allTokens),
client.hincrby(totalKey, 'totalRequests', 1),
// 每日统计
client.hincrby(dailyKey, 'tokens', coreTokens),
client.hincrby(dailyKey, 'inputTokens', inputTokens),
client.hincrby(dailyKey, 'outputTokens', outputTokens),
client.hincrby(dailyKey, 'cacheCreateTokens', cacheCreateTokens),
client.hincrby(dailyKey, 'cacheReadTokens', cacheReadTokens),
client.hincrby(dailyKey, 'allTokens', allTokens),
client.hincrby(dailyKey, 'requests', 1),
// 每月统计
client.hincrby(monthlyKey, 'tokens', coreTokens),
client.hincrby(monthlyKey, 'inputTokens', inputTokens),
client.hincrby(monthlyKey, 'outputTokens', outputTokens),
client.hincrby(monthlyKey, 'cacheCreateTokens', cacheCreateTokens),
client.hincrby(monthlyKey, 'cacheReadTokens', cacheReadTokens),
client.hincrby(monthlyKey, 'allTokens', allTokens),
client.hincrby(monthlyKey, 'requests', 1),
// 模型统计 - 每日
client.hincrby(modelDailyKey, 'totalInputTokens', inputTokens),
client.hincrby(modelDailyKey, 'totalOutputTokens', outputTokens),
client.hincrby(modelDailyKey, 'totalCacheCreateTokens', cacheCreateTokens),
client.hincrby(modelDailyKey, 'totalCacheReadTokens', cacheReadTokens),
client.hincrby(modelDailyKey, 'totalAllTokens', allTokens),
client.hincrby(modelDailyKey, 'requests', 1),
// 模型统计 - 每月
client.hincrby(modelMonthlyKey, 'totalInputTokens', inputTokens),
client.hincrby(modelMonthlyKey, 'totalOutputTokens', outputTokens),
client.hincrby(modelMonthlyKey, 'totalCacheCreateTokens', cacheCreateTokens),
client.hincrby(modelMonthlyKey, 'totalCacheReadTokens', cacheReadTokens),
client.hincrby(modelMonthlyKey, 'totalAllTokens', allTokens),
client.hincrby(modelMonthlyKey, 'requests', 1),
// API Key级别的模型统计 - 每日
// 同时存储带total前缀和不带前缀的字段保持兼容性
client.hincrby(keyModelDailyKey, 'inputTokens', inputTokens),
client.hincrby(keyModelDailyKey, 'outputTokens', outputTokens),
client.hincrby(keyModelDailyKey, 'cacheCreateTokens', cacheCreateTokens),
client.hincrby(keyModelDailyKey, 'cacheReadTokens', cacheReadTokens),
client.hincrby(keyModelDailyKey, 'allTokens', allTokens),
client.hincrby(keyModelDailyKey, 'totalInputTokens', inputTokens),
client.hincrby(keyModelDailyKey, 'totalOutputTokens', outputTokens),
client.hincrby(keyModelDailyKey, 'totalCacheCreateTokens', cacheCreateTokens),
client.hincrby(keyModelDailyKey, 'totalCacheReadTokens', cacheReadTokens),
client.hincrby(keyModelDailyKey, 'totalAllTokens', allTokens),
client.hincrby(keyModelDailyKey, 'requests', 1),
// API Key级别的模型统计 - 每月
client.hincrby(keyModelMonthlyKey, 'inputTokens', inputTokens),
client.hincrby(keyModelMonthlyKey, 'outputTokens', outputTokens),
client.hincrby(keyModelMonthlyKey, 'cacheCreateTokens', cacheCreateTokens),
client.hincrby(keyModelMonthlyKey, 'cacheReadTokens', cacheReadTokens),
client.hincrby(keyModelMonthlyKey, 'allTokens', allTokens),
client.hincrby(keyModelMonthlyKey, 'totalInputTokens', inputTokens),
client.hincrby(keyModelMonthlyKey, 'totalOutputTokens', outputTokens),
client.hincrby(keyModelMonthlyKey, 'totalCacheCreateTokens', cacheCreateTokens),
client.hincrby(keyModelMonthlyKey, 'totalCacheReadTokens', cacheReadTokens),
client.hincrby(keyModelMonthlyKey, 'totalAllTokens', allTokens),
client.hincrby(keyModelMonthlyKey, 'requests', 1),
]);
}
}
// 清除测试数据
async function cleanTestData() {
const client = redis.getClientSafe();
const apiKeyService = require('../src/services/apiKeyService');
logger.info('🧹 Cleaning test data...');
// 获取所有API Keys
const allKeys = await apiKeyService.getAllApiKeys();
// 找出所有测试 API Keys
const testKeys = allKeys.filter(key => key.name && key.name.startsWith('Test API Key'));
for (const testKey of testKeys) {
const apiKeyId = testKey.id;
// 获取所有相关的键
const patterns = [
`usage:${apiKeyId}`,
`usage:daily:${apiKeyId}:*`,
`usage:monthly:${apiKeyId}:*`,
`usage:${apiKeyId}:model:daily:*`,
`usage:${apiKeyId}:model:monthly:*`
];
for (const pattern of patterns) {
const keys = await client.keys(pattern);
if (keys.length > 0) {
await client.del(...keys);
logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`);
}
}
// 删除 API Key 本身
await apiKeyService.deleteApiKey(apiKeyId);
logger.info(`🗑️ Deleted test API Key: ${testKey.name} (${apiKeyId})`);
}
// 清除模型统计
const modelPatterns = [
'usage:model:daily:*',
'usage:model:monthly:*'
];
for (const pattern of modelPatterns) {
const keys = await client.keys(pattern);
if (keys.length > 0) {
await client.del(...keys);
logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`);
}
}
}
// 主函数
async function main() {
try {
await redis.connect();
logger.success('✅ Connected to Redis');
// 创建测试API Keys
const apiKeyService = require('../src/services/apiKeyService');
let testApiKeys = [];
let createdKeys = [];
// 总是创建新的测试 API Keys
logger.info('📝 Creating test API Keys...');
for (let i = 1; i <= 3; i++) {
const newKey = await apiKeyService.generateApiKey({
name: `Test API Key ${i}`,
description: `Test key for historical data generation ${i}`,
tokenLimit: 10000000,
concurrencyLimit: 10,
rateLimitWindow: 60,
rateLimitRequests: 100
});
testApiKeys.push(newKey.id);
createdKeys.push(newKey);
logger.success(`✅ Created test API Key: ${newKey.name} (${newKey.id})`);
logger.info(` 🔑 API Key: ${newKey.apiKey}`);
}
if (shouldClean) {
await cleanTestData();
logger.success('✅ Test data cleaned successfully');
return;
}
// 生成历史数据
const now = new Date();
for (const apiKeyId of testApiKeys) {
logger.info(`\n🔄 Generating data for API Key: ${apiKeyId}`);
// 生成过去30天的数据
for (let dayOffset = 0; dayOffset < 30; dayOffset++) {
const date = new Date(now);
date.setDate(date.getDate() - dayOffset);
await generateDataForDate(apiKeyId, date, dayOffset);
}
logger.success(`✅ Generated 30 days of historical data for API Key: ${apiKeyId}`);
}
// 显示统计摘要
logger.info('\n📊 Test Data Summary:');
logger.info('='.repeat(60));
for (const apiKeyId of testApiKeys) {
const totalKey = `usage:${apiKeyId}`;
const totalData = await redis.getClientSafe().hgetall(totalKey);
if (totalData && Object.keys(totalData).length > 0) {
logger.info(`\nAPI Key: ${apiKeyId}`);
logger.info(` Total Requests: ${totalData.totalRequests || 0}`);
logger.info(` Total Tokens (Core): ${totalData.totalTokens || 0}`);
logger.info(` Total Tokens (All): ${totalData.totalAllTokens || 0}`);
logger.info(` Input Tokens: ${totalData.totalInputTokens || 0}`);
logger.info(` Output Tokens: ${totalData.totalOutputTokens || 0}`);
logger.info(` Cache Create Tokens: ${totalData.totalCacheCreateTokens || 0}`);
logger.info(` Cache Read Tokens: ${totalData.totalCacheReadTokens || 0}`);
}
}
logger.info('\n' + '='.repeat(60));
logger.success('\n✅ Test data generation completed!');
logger.info('\n📋 Created API Keys:');
for (const key of createdKeys) {
logger.info(`- ${key.name}: ${key.apiKey}`);
}
logger.info('\n💡 Tips:');
logger.info('- Check the admin panel to see the different time ranges');
logger.info('- Use --clean flag to remove all test data and API Keys');
logger.info('- The script generates more recent data to simulate real usage patterns');
} catch (error) {
logger.error('❌ Error:', error);
} finally {
await redis.disconnect();
}
}
// 运行脚本
main().catch(error => {
logger.error('💥 Unexpected error:', error);
process.exit(1);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -324,11 +324,13 @@ class RedisClient {
const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0; const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0;
const totalFromSeparate = inputTokens + outputTokens; const totalFromSeparate = inputTokens + outputTokens;
// 计算实际的总tokens包含所有类型
const actualAllTokens = allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens);
if (totalFromSeparate === 0 && tokens > 0) { if (totalFromSeparate === 0 && tokens > 0) {
// 旧数据:没有输入输出分离 // 旧数据:没有输入输出分离
return { return {
tokens, tokens: tokens, // 保持兼容性但统一使用allTokens
inputTokens: Math.round(tokens * 0.3), // 假设30%为输入 inputTokens: Math.round(tokens * 0.3), // 假设30%为输入
outputTokens: Math.round(tokens * 0.7), // 假设70%为输出 outputTokens: Math.round(tokens * 0.7), // 假设70%为输出
cacheCreateTokens: 0, // 旧数据没有缓存token cacheCreateTokens: 0, // 旧数据没有缓存token
@@ -337,14 +339,14 @@ class RedisClient {
requests requests
}; };
} else { } else {
// 新数据或无数据 // 新数据或无数据 - 统一使用allTokens作为tokens的值
return { return {
tokens, tokens: actualAllTokens, // 统一使用allTokens作为总数
inputTokens, inputTokens,
outputTokens, outputTokens,
cacheCreateTokens, cacheCreateTokens,
cacheReadTokens, cacheReadTokens,
allTokens: allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens), // 计算或使用存储的值 allTokens: actualAllTokens,
requests requests
}; };
} }

View File

@@ -20,7 +20,215 @@ const router = express.Router();
// 获取所有API Keys // 获取所有API Keys
router.get('/api-keys', authenticateAdmin, async (req, res) => { router.get('/api-keys', authenticateAdmin, async (req, res) => {
try { try {
const { timeRange = 'all' } = req.query; // all, 7days, monthly
const apiKeys = await apiKeyService.getAllApiKeys(); const apiKeys = await apiKeyService.getAllApiKeys();
// 根据时间范围计算查询模式
const now = new Date();
let searchPatterns = [];
if (timeRange === '7days') {
// 最近7天
for (let i = 0; i < 7; i++) {
const date = new Date(now);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
searchPatterns.push(`usage:daily:*:${dateStr}`);
}
} else if (timeRange === 'monthly') {
// 本月
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
searchPatterns.push(`usage:monthly:*:${currentMonth}`);
}
// 为每个API Key计算准确的费用和统计数据
for (const apiKey of apiKeys) {
const client = redis.getClientSafe();
if (timeRange === 'all') {
// 全部时间:保持原有逻辑
if (apiKey.usage && apiKey.usage.total) {
// 使用与展开模型统计相同的数据源
// 获取所有时间的模型统计数据
const monthlyKeys = await client.keys(`usage:${apiKey.id}:model:monthly:*:*`);
const modelStatsMap = new Map();
// 汇总所有月份的数据
for (const key of monthlyKeys) {
const match = key.match(/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/);
if (!match) continue;
const model = match[1];
const data = await client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!modelStatsMap.has(model)) {
modelStatsMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
});
}
const stats = modelStatsMap.get(model);
stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
stats.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
stats.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
stats.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
}
}
let totalCost = 0;
// 计算每个模型的费用
for (const [model, stats] of modelStatsMap) {
const usage = {
input_tokens: stats.inputTokens,
output_tokens: stats.outputTokens,
cache_creation_input_tokens: stats.cacheCreateTokens,
cache_read_input_tokens: stats.cacheReadTokens
};
const costResult = CostCalculator.calculateCost(usage, model);
totalCost += costResult.costs.total;
}
// 如果没有详细的模型数据,使用总量数据和默认模型计算
if (modelStatsMap.size === 0) {
const usage = {
input_tokens: apiKey.usage.total.inputTokens || 0,
output_tokens: apiKey.usage.total.outputTokens || 0,
cache_creation_input_tokens: apiKey.usage.total.cacheCreateTokens || 0,
cache_read_input_tokens: apiKey.usage.total.cacheReadTokens || 0
};
const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022');
totalCost = costResult.costs.total;
}
// 添加格式化的费用到响应数据
apiKey.usage.total.cost = totalCost;
apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost);
}
} else {
// 7天或本月重新计算统计数据
const tempUsage = {
requests: 0,
tokens: 0,
allTokens: 0, // 添加allTokens字段
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
};
// 获取指定时间范围的统计数据
for (const pattern of searchPatterns) {
const keys = await client.keys(pattern.replace('*', apiKey.id));
for (const key of keys) {
const data = await client.hgetall(key);
if (data && Object.keys(data).length > 0) {
// 使用与 redis.js incrementTokenUsage 中相同的字段名
tempUsage.requests += parseInt(data.totalRequests) || parseInt(data.requests) || 0;
tempUsage.tokens += parseInt(data.totalTokens) || parseInt(data.tokens) || 0;
tempUsage.allTokens += parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0; // 读取包含所有Token的字段
tempUsage.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
tempUsage.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
tempUsage.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
tempUsage.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
}
}
}
// 计算指定时间范围的费用
let totalCost = 0;
const modelKeys = timeRange === '7days'
? await client.keys(`usage:${apiKey.id}:model:daily:*:*`)
: await client.keys(`usage:${apiKey.id}:model:monthly:*:${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`);
const modelStatsMap = new Map();
// 过滤和汇总相应时间范围的模型数据
for (const key of modelKeys) {
if (timeRange === '7days') {
// 检查是否在最近7天内
const dateMatch = key.match(/\d{4}-\d{2}-\d{2}$/);
if (dateMatch) {
const keyDate = new Date(dateMatch[0]);
const daysDiff = Math.floor((now - keyDate) / (1000 * 60 * 60 * 24));
if (daysDiff > 6) continue;
}
}
const modelMatch = key.match(/usage:.+:model:(?:daily|monthly):(.+):\d{4}-\d{2}(?:-\d{2})?$/);
if (!modelMatch) continue;
const model = modelMatch[1];
const data = await client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!modelStatsMap.has(model)) {
modelStatsMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
});
}
const stats = modelStatsMap.get(model);
stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
stats.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
stats.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
stats.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
}
}
// 计算费用
for (const [model, stats] of modelStatsMap) {
const usage = {
input_tokens: stats.inputTokens,
output_tokens: stats.outputTokens,
cache_creation_input_tokens: stats.cacheCreateTokens,
cache_read_input_tokens: stats.cacheReadTokens
};
const costResult = CostCalculator.calculateCost(usage, model);
totalCost += costResult.costs.total;
}
// 如果没有模型数据,使用临时统计数据计算
if (modelStatsMap.size === 0 && tempUsage.tokens > 0) {
const usage = {
input_tokens: tempUsage.inputTokens,
output_tokens: tempUsage.outputTokens,
cache_creation_input_tokens: tempUsage.cacheCreateTokens,
cache_read_input_tokens: tempUsage.cacheReadTokens
};
const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022');
totalCost = costResult.costs.total;
}
// 使用从Redis读取的allTokens如果没有则计算
const allTokens = tempUsage.allTokens || (tempUsage.inputTokens + tempUsage.outputTokens + tempUsage.cacheCreateTokens + tempUsage.cacheReadTokens);
// 更新API Key的usage数据为指定时间范围的数据
apiKey.usage[timeRange] = {
...tempUsage,
tokens: allTokens, // 使用包含所有Token的总数
allTokens: allTokens,
cost: totalCost,
formattedCost: CostCalculator.formatCost(totalCost)
};
// 为了保持兼容性也更新total字段
apiKey.usage.total = apiKey.usage[timeRange];
}
}
res.json({ success: true, data: apiKeys }); res.json({ success: true, data: apiKeys });
} catch (error) { } catch (error) {
logger.error('❌ Failed to get API keys:', error); logger.error('❌ Failed to get API keys:', error);
@@ -112,7 +320,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
try { try {
const { keyId } = req.params; 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 = {}; const updates = {};
@@ -178,6 +386,21 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
updates.restrictedModels = restrictedModels; 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); await apiKeyService.updateApiKey(keyId, updates);
logger.success(`📝 Admin updated API key: ${keyId}`); logger.success(`📝 Admin updated API key: ${keyId}`);
@@ -582,8 +805,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
redis.getSystemAverages() redis.getSystemAverages()
]); ]);
// 计算使用统计(包含cache tokens // 计算使用统计(统一使用allTokens
const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0); 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 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 totalInputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.inputTokens || 0), 0);
const totalOutputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.outputTokens || 0), 0); const totalOutputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.outputTokens || 0), 0);

View File

@@ -283,14 +283,17 @@ class ApiKeyService {
let cleanedCount = 0; let cleanedCount = 0;
for (const key of apiKeys) { 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++; cleanedCount++;
} }
} }
if (cleanedCount > 0) { if (cleanedCount > 0) {
logger.success(`🧹 Cleaned up ${cleanedCount} expired API keys`); logger.success(`🧹 Disabled ${cleanedCount} expired API keys`);
} }
return cleanedCount; return cleanedCount;

View File

@@ -111,6 +111,7 @@ const app = createApp({
// API Keys // API Keys
apiKeys: [], apiKeys: [],
apiKeysLoading: false, apiKeysLoading: false,
apiKeyStatsTimeRange: 'all', // API Key统计时间范围all, 7days, monthly
showCreateApiKeyModal: false, showCreateApiKeyModal: false,
createApiKeyLoading: false, createApiKeyLoading: false,
apiKeyForm: { apiKeyForm: {
@@ -125,7 +126,10 @@ const app = createApp({
permissions: 'all', // 'claude', 'gemini', 'all' permissions: 'all', // 'claude', 'gemini', 'all'
enableModelRestriction: false, enableModelRestriction: false,
restrictedModels: [], restrictedModels: [],
modelInput: '' modelInput: '',
expireDuration: '', // 过期时长选择
customExpireDate: '', // 自定义过期日期
expiresAt: null // 实际的过期时间戳
}, },
apiKeyModelStats: {}, // 存储每个key的模型统计数据 apiKeyModelStats: {}, // 存储每个key的模型统计数据
expandedApiKeys: {}, // 跟踪展开的API Keys expandedApiKeys: {}, // 跟踪展开的API Keys
@@ -155,6 +159,18 @@ const app = createApp({
showFullKey: false showFullKey: false
}, },
// API Key续期
showRenewApiKeyModal: false,
renewApiKeyLoading: false,
renewApiKeyForm: {
id: '',
name: '',
currentExpiresAt: null,
renewDuration: '30d',
customExpireDate: '',
newExpiresAt: null
},
// 编辑API Key // 编辑API Key
showEditApiKeyModal: false, showEditApiKeyModal: false,
editApiKeyLoading: false, editApiKeyLoading: false,
@@ -284,6 +300,13 @@ const app = createApp({
return this.accounts.filter(account => return this.accounts.filter(account =>
account.accountType === 'dedicated' && account.isActive === true account.accountType === 'dedicated' && account.isActive === true
); );
},
// 计算最小日期时间(当前时间)
minDateTime() {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
return now.toISOString().slice(0, 16);
} }
}, },
@@ -569,6 +592,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() { openCreateAccountModal() {
console.log('Opening Account modal...'); console.log('Opening Account modal...');
@@ -1691,9 +1780,9 @@ const app = createApp({
async loadApiKeys() { async loadApiKeys() {
this.apiKeysLoading = true; this.apiKeysLoading = true;
console.log('Loading API Keys...'); console.log('Loading API Keys with time range:', this.apiKeyStatsTimeRange);
try { try {
const data = await this.apiRequest('/admin/api-keys'); const data = await this.apiRequest(`/admin/api-keys?timeRange=${this.apiKeyStatsTimeRange}`);
if (!data) { if (!data) {
// 如果token过期apiRequest会返回null并刷新页面 // 如果token过期apiRequest会返回null并刷新页面
@@ -1826,7 +1915,8 @@ const app = createApp({
geminiAccountId: this.apiKeyForm.geminiAccountId || null, geminiAccountId: this.apiKeyForm.geminiAccountId || null,
permissions: this.apiKeyForm.permissions || 'all', permissions: this.apiKeyForm.permissions || 'all',
enableModelRestriction: this.apiKeyForm.enableModelRestriction, enableModelRestriction: this.apiKeyForm.enableModelRestriction,
restrictedModels: this.apiKeyForm.restrictedModels restrictedModels: this.apiKeyForm.restrictedModels,
expiresAt: this.apiKeyForm.expiresAt
}) })
}); });
@@ -1847,7 +1937,23 @@ const app = createApp({
// 关闭创建弹窗并清理表单 // 关闭创建弹窗并清理表单
this.showCreateApiKeyModal = false; 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列表 // 重新加载API Keys列表
await this.loadApiKeys(); await this.loadApiKeys();
@@ -1893,6 +1999,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) { openEditApiKeyModal(key) {
this.editApiKeyForm = { this.editApiKeyForm = {
id: key.id, id: key.id,
@@ -2931,23 +3142,13 @@ const app = createApp({
calculateApiKeyCost(usage) { calculateApiKeyCost(usage) {
if (!usage || !usage.total) return '$0.000000'; if (!usage || !usage.total) return '$0.000000';
// 使用通用模型价格估算 // 使用后端返回的准确费用数据
const totalInputTokens = usage.total.inputTokens || 0; if (usage.total.formattedCost) {
const totalOutputTokens = usage.total.outputTokens || 0; return usage.total.formattedCost;
const totalCacheCreateTokens = usage.total.cacheCreateTokens || 0; }
const totalCacheReadTokens = usage.total.cacheReadTokens || 0;
// 简单估算使用Claude 3.5 Sonnet价格 // 如果没有后端费用数据,返回默认值
const inputCost = (totalInputTokens / 1000000) * 3.00; return '$0.000000';
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);
}, },
// 初始化日期筛选器 // 初始化日期筛选器

View File

@@ -538,12 +538,24 @@
<h3 class="text-xl font-bold text-gray-900 mb-2">API Keys 管理</h3> <h3 class="text-xl font-bold text-gray-900 mb-2">API Keys 管理</h3>
<p class="text-gray-600">管理和监控您的 API 密钥</p> <p class="text-gray-600">管理和监控您的 API 密钥</p>
</div> </div>
<button <div class="flex items-center gap-3">
@click.stop="openCreateApiKeyModal" <!-- Token统计时间范围选择 -->
class="btn btn-primary px-6 py-3 flex items-center gap-2" <select
> v-model="apiKeyStatsTimeRange"
<i class="fas fa-plus"></i>创建新 Key @change="loadApiKeys()"
</button> class="form-select px-4 py-2 bg-white border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="all">全部时间</option>
<option value="7days">最近7天</option>
<option value="monthly">本月</option>
</select>
<button
@click.stop="openCreateApiKeyModal"
class="btn btn-primary px-6 py-3 flex items-center gap-2"
>
<i class="fas fa-plus"></i>创建新 Key
</button>
</div>
</div> </div>
<div v-if="apiKeysLoading" class="text-center py-12"> <div v-if="apiKeysLoading" class="text-center py-12">
@@ -568,6 +580,7 @@
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">状态</th> <th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">状态</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">使用统计</th> <th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">使用统计</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">创建时间</th> <th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">创建时间</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">过期时间</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th> <th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
</tr> </tr>
</thead> </thead>
@@ -654,6 +667,11 @@
<span>输入: {{ formatNumber((key.usage && key.usage.total && key.usage.total.inputTokens) || 0) }}</span> <span>输入: {{ formatNumber((key.usage && key.usage.total && key.usage.total.inputTokens) || 0) }}</span>
<span>输出: {{ formatNumber((key.usage && key.usage.total && key.usage.total.outputTokens) || 0) }}</span> <span>输出: {{ formatNumber((key.usage && key.usage.total && key.usage.total.outputTokens) || 0) }}</span>
</div> </div>
<!-- 缓存Token细节 -->
<div v-if="((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) > 0 || ((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) > 0" class="flex justify-between text-xs text-orange-500">
<span>缓存创建: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) }}</span>
<span>缓存读取: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) }}</span>
</div>
<!-- RPM/TPM --> <!-- RPM/TPM -->
<div class="flex justify-between text-xs text-blue-600"> <div class="flex justify-between text-xs text-blue-600">
<span>RPM: {{ (key.usage && key.usage.averages && key.usage.averages.rpm) || 0 }}</span> <span>RPM: {{ (key.usage && key.usage.averages && key.usage.averages.rpm) || 0 }}</span>
@@ -678,6 +696,25 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ new Date(key.createdAt).toLocaleDateString() }} {{ new Date(key.createdAt).toLocaleDateString() }}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div v-if="key.expiresAt">
<div v-if="isApiKeyExpired(key.expiresAt)" class="text-red-600">
<i class="fas fa-exclamation-circle mr-1"></i>
已过期
</div>
<div v-else-if="isApiKeyExpiringSoon(key.expiresAt)" class="text-orange-600">
<i class="fas fa-clock mr-1"></i>
{{ formatExpireDate(key.expiresAt) }}
</div>
<div v-else class="text-gray-600">
{{ formatExpireDate(key.expiresAt) }}
</div>
</div>
<div v-else class="text-gray-400">
<i class="fas fa-infinity mr-1"></i>
永不过期
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm"> <td class="px-6 py-4 whitespace-nowrap text-sm">
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
@@ -686,6 +723,13 @@
> >
<i class="fas fa-edit mr-1"></i>编辑 <i class="fas fa-edit mr-1"></i>编辑
</button> </button>
<button
v-if="key.expiresAt && (isApiKeyExpired(key.expiresAt) || isApiKeyExpiringSoon(key.expiresAt))"
@click="openRenewApiKeyModal(key)"
class="text-green-600 hover:text-green-900 font-medium hover:bg-green-50 px-3 py-1 rounded-lg transition-colors"
>
<i class="fas fa-clock mr-1"></i>续期
</button>
<button <button
@click="deleteApiKey(key.id)" @click="deleteApiKey(key.id)"
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors" class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
@@ -2000,6 +2044,36 @@
></textarea> ></textarea>
</div> </div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">有效期限</label>
<select
v-model="apiKeyForm.expireDuration"
@change="updateExpireAt"
class="form-input w-full"
>
<option value="">永不过期</option>
<option value="1d">1 天</option>
<option value="7d">7 天</option>
<option value="30d">30 天</option>
<option value="90d">90 天</option>
<option value="180d">180 天</option>
<option value="365d">365 天</option>
<option value="custom">自定义日期</option>
</select>
<div v-if="apiKeyForm.expireDuration === 'custom'" class="mt-3">
<input
v-model="apiKeyForm.customExpireDate"
type="datetime-local"
class="form-input w-full"
:min="minDateTime"
@change="updateCustomExpireAt"
>
</div>
<p v-if="apiKeyForm.expiresAt" class="text-xs text-gray-500 mt-2">
将于 {{ formatExpireDate(apiKeyForm.expiresAt) }} 过期
</p>
</div>
<div> <div>
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label> <label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
<div class="flex gap-4"> <div class="flex gap-4">
@@ -2410,6 +2484,92 @@
</div> </div>
</div> </div>
<!-- API Key 续期弹窗 -->
<div v-if="showRenewApiKeyModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
<i class="fas fa-clock text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900">续期 API Key</h3>
</div>
<button
@click="closeRenewApiKeyModal"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-info text-white text-sm"></i>
</div>
<div>
<h4 class="font-semibold text-gray-800 mb-1">API Key 信息</h4>
<p class="text-sm text-gray-700">{{ renewApiKeyForm.name }}</p>
<p class="text-xs text-gray-600 mt-1">
当前过期时间:{{ renewApiKeyForm.currentExpiresAt ? formatExpireDate(renewApiKeyForm.currentExpiresAt) : '永不过期' }}
</p>
</div>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">续期时长</label>
<select
v-model="renewApiKeyForm.renewDuration"
@change="updateRenewExpireAt"
class="form-input w-full"
>
<option value="7d">延长 7 天</option>
<option value="30d">延长 30 天</option>
<option value="90d">延长 90 天</option>
<option value="180d">延长 180 天</option>
<option value="365d">延长 365 天</option>
<option value="custom">自定义日期</option>
<option value="permanent">设为永不过期</option>
</select>
<div v-if="renewApiKeyForm.renewDuration === 'custom'" class="mt-3">
<input
v-model="renewApiKeyForm.customExpireDate"
type="datetime-local"
class="form-input w-full"
:min="minDateTime"
@change="updateCustomRenewExpireAt"
>
</div>
<p v-if="renewApiKeyForm.newExpiresAt" class="text-xs text-gray-500 mt-2">
新的过期时间:{{ formatExpireDate(renewApiKeyForm.newExpiresAt) }}
</p>
</div>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
@click="closeRenewApiKeyModal"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
>
取消
</button>
<button
type="button"
@click="renewApiKey"
:disabled="renewApiKeyLoading || !renewApiKeyForm.renewDuration"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
>
<div v-if="renewApiKeyLoading" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-clock mr-2"></i>
{{ renewApiKeyLoading ? '续期中...' : '确认续期' }}
</button>
</div>
</div>
</div>
<!-- 新创建的 API Key 展示弹窗 --> <!-- 新创建的 API Key 展示弹窗 -->
<div v-if="showNewApiKeyModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4"> <div v-if="showNewApiKeyModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-lg p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar"> <div class="modal-content w-full max-w-lg p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">