fix: APIKey列表费用及Token显示不准确的问题,目前显示总数

feat: 增加APIKey过期设置,以及到期续期的能力
This commit is contained in:
KevinLiao
2025-07-25 09:34:40 +08:00
parent 561f5ffc7f
commit f614d54ab5
19 changed files with 3908 additions and 90 deletions

View File

@@ -4,7 +4,7 @@ const { Command } = require('commander');
const inquirer = require('inquirer');
const chalk = require('chalk');
const ora = require('ora');
const Table = require('table').table;
const { table } = require('table');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const fs = require('fs');
@@ -54,6 +54,43 @@ program
});
// 🔑 API Key 管理
program
.command('keys')
.description('API Key 管理操作')
.action(async () => {
await initialize();
const { action } = await inquirer.prompt([{
type: 'list',
name: 'action',
message: '请选择操作:',
choices: [
{ name: '📋 查看所有 API Keys', value: 'list' },
{ name: '🔧 修改 API Key 过期时间', value: 'update-expiry' },
{ name: '🔄 续期即将过期的 API Key', value: 'renew' },
{ name: '🗑️ 删除 API Key', value: 'delete' }
]
}]);
switch (action) {
case 'list':
await listApiKeys();
break;
case 'update-expiry':
await updateApiKeyExpiry();
break;
case 'renew':
await renewApiKeys();
break;
case 'delete':
await deleteApiKey();
break;
}
await redis.disconnect();
});
// 📊 系统状态
program
.command('status')
@@ -201,6 +238,329 @@ async function createInitialAdmin() {
// API Key 管理功能
async function listApiKeys() {
const spinner = ora('正在获取 API Keys...').start();
try {
const apiKeys = await apiKeyService.getAllApiKeys();
spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`);
if (apiKeys.length === 0) {
console.log(styles.warning('没有找到任何 API Keys'));
return;
}
const tableData = [
['名称', 'API Key', '状态', '过期时间', '使用量', 'Token限制']
];
apiKeys.forEach(key => {
const now = new Date();
const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null;
let expiryStatus = '永不过期';
if (expiresAt) {
if (expiresAt < now) {
expiryStatus = styles.error(`已过期 (${expiresAt.toLocaleDateString()})`);
} else {
const daysLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
if (daysLeft <= 7) {
expiryStatus = styles.warning(`${daysLeft}天后过期 (${expiresAt.toLocaleDateString()})`);
} else {
expiryStatus = styles.success(`${expiresAt.toLocaleDateString()}`);
}
}
}
tableData.push([
key.name,
key.apiKey ? key.apiKey.substring(0, 20) + '...' : '-',
key.isActive ? '🟢 活跃' : '🔴 停用',
expiryStatus,
`${(key.usage?.total?.tokens || 0).toLocaleString()}`,
key.tokenLimit ? key.tokenLimit.toLocaleString() : '无限制'
]);
});
console.log(styles.title('\n🔑 API Keys 列表:\n'));
console.log(table(tableData));
} catch (error) {
spinner.fail('获取 API Keys 失败');
console.error(styles.error(error.message));
}
}
async function updateApiKeyExpiry() {
try {
// 获取所有 API Keys
const apiKeys = await apiKeyService.getAllApiKeys();
if (apiKeys.length === 0) {
console.log(styles.warning('没有找到任何 API Keys'));
return;
}
// 选择要修改的 API Key
const { selectedKey } = await inquirer.prompt([{
type: 'list',
name: 'selectedKey',
message: '选择要修改的 API Key:',
choices: apiKeys.map(key => ({
name: `${key.name} (${key.apiKey?.substring(0, 20)}...) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`,
value: key
}))
}]);
console.log(`\n当前 API Key: ${selectedKey.name}`);
console.log(`当前过期时间: ${selectedKey.expiresAt ? new Date(selectedKey.expiresAt).toLocaleString() : '永不过期'}`);
// 选择新的过期时间
const { expiryOption } = await inquirer.prompt([{
type: 'list',
name: 'expiryOption',
message: '选择新的过期时间:',
choices: [
{ name: '⏰ 1分后测试用', value: '1m' },
{ name: '⏰ 1小时后测试用', value: '1h' },
{ name: '📅 1天后', value: '1d' },
{ name: '📅 7天后', value: '7d' },
{ name: '📅 30天后', value: '30d' },
{ name: '📅 90天后', value: '90d' },
{ name: '📅 365天后', value: '365d' },
{ name: '♾️ 永不过期', value: 'never' },
{ name: '🎯 自定义日期时间', value: 'custom' }
]
}]);
let newExpiresAt = null;
if (expiryOption === 'never') {
newExpiresAt = null;
} else if (expiryOption === 'custom') {
const { customDate, customTime } = await inquirer.prompt([
{
type: 'input',
name: 'customDate',
message: '输入日期 (YYYY-MM-DD):',
default: new Date().toISOString().split('T')[0],
validate: input => {
const date = new Date(input);
return !isNaN(date.getTime()) || '请输入有效的日期格式';
}
},
{
type: 'input',
name: 'customTime',
message: '输入时间 (HH:MM):',
default: '00:00',
validate: input => {
return /^\d{2}:\d{2}$/.test(input) || '请输入有效的时间格式 (HH:MM)';
}
}
]);
newExpiresAt = new Date(`${customDate}T${customTime}:00`).toISOString();
} else {
// 计算新的过期时间
const now = new Date();
const durations = {
'1m': 60 * 1000,
'1h': 60 * 60 * 1000,
'1d': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
'90d': 90 * 24 * 60 * 60 * 1000,
'365d': 365 * 24 * 60 * 60 * 1000
};
newExpiresAt = new Date(now.getTime() + durations[expiryOption]).toISOString();
}
// 确认修改
const confirmMsg = newExpiresAt
? `确认将过期时间修改为: ${new Date(newExpiresAt).toLocaleString()}?`
: '确认设置为永不过期?';
const { confirmed } = await inquirer.prompt([{
type: 'confirm',
name: 'confirmed',
message: confirmMsg,
default: true
}]);
if (!confirmed) {
console.log(styles.info('已取消修改'));
return;
}
// 执行修改
const spinner = ora('正在修改过期时间...').start();
try {
await apiKeyService.updateApiKey(selectedKey.id, { expiresAt: newExpiresAt });
spinner.succeed('过期时间修改成功');
console.log(styles.success(`\n✅ API Key "${selectedKey.name}" 的过期时间已更新`));
console.log(`新的过期时间: ${newExpiresAt ? new Date(newExpiresAt).toLocaleString() : '永不过期'}`);
} catch (error) {
spinner.fail('修改失败');
console.error(styles.error(error.message));
}
} catch (error) {
console.error(styles.error('操作失败:', error.message));
}
}
async function renewApiKeys() {
const spinner = ora('正在查找即将过期的 API Keys...').start();
try {
const apiKeys = await apiKeyService.getAllApiKeys();
const now = new Date();
const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
// 筛选即将过期的 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() {
const spinner = ora('正在获取 Claude 账户...').start();
@@ -251,6 +611,7 @@ if (!process.argv.slice(2).length) {
console.log(styles.title('🚀 Claude Relay Service CLI\n'));
console.log('使用以下命令管理服务:\n');
console.log(' claude-relay-cli admin - 创建初始管理员账户');
console.log(' claude-relay-cli keys - API Key 管理(查看/修改过期时间/续期/删除)');
console.log(' claude-relay-cli status - 查看系统状态');
console.log('\n使用 --help 查看详细帮助信息');
}

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

View File

@@ -26,7 +26,17 @@
"lint": "eslint src/**/*.js",
"docker:build": "docker build -t claude-relay-service .",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down"
"docker:down": "docker-compose down",
"migrate:apikey-expiry": "node scripts/migrate-apikey-expiry.js",
"migrate:apikey-expiry:dry": "node scripts/migrate-apikey-expiry.js --dry-run",
"migrate:fix-usage-stats": "node scripts/fix-usage-stats.js",
"data:export": "node scripts/data-transfer.js export",
"data:import": "node scripts/data-transfer.js import",
"data:export:sanitized": "node scripts/data-transfer.js export --sanitize",
"data:export:enhanced": "node scripts/data-transfer-enhanced.js export",
"data:export:encrypted": "node scripts/data-transfer-enhanced.js export --decrypt=false",
"data:import:enhanced": "node scripts/data-transfer-enhanced.js import",
"data:debug": "node scripts/debug-redis-keys.js"
},
"dependencies": {
"axios": "^1.6.0",
@@ -40,7 +50,7 @@
"google-auth-library": "^10.1.0",
"helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2",
"inquirer": "^9.2.15",
"inquirer": "^8.2.6",
"ioredis": "^5.3.2",
"morgan": "^1.10.0",
"ora": "^5.4.1",

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

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

View File

@@ -21,6 +21,77 @@ const router = express.Router();
router.get('/api-keys', authenticateAdmin, async (req, res) => {
try {
const apiKeys = await apiKeyService.getAllApiKeys();
// 为每个API Key计算准确的费用
for (const apiKey of apiKeys) {
if (apiKey.usage && apiKey.usage.total) {
const client = redis.getClientSafe();
// 使用与展开模型统计相同的数据源
// 获取所有时间的模型统计数据
const monthlyKeys = await client.keys(`usage:${apiKey.id}:model:monthly:*:*`);
const modelStatsMap = new Map();
// 汇总所有月份的数据
for (const key of monthlyKeys) {
const match = key.match(/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/);
if (!match) continue;
const model = match[1];
const data = await client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!modelStatsMap.has(model)) {
modelStatsMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
});
}
const stats = modelStatsMap.get(model);
stats.inputTokens += parseInt(data.inputTokens) || 0;
stats.outputTokens += parseInt(data.outputTokens) || 0;
stats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0;
stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0;
}
}
let totalCost = 0;
// 计算每个模型的费用
for (const [model, stats] of modelStatsMap) {
const usage = {
input_tokens: stats.inputTokens,
output_tokens: stats.outputTokens,
cache_creation_input_tokens: stats.cacheCreateTokens,
cache_read_input_tokens: stats.cacheReadTokens
};
const costResult = CostCalculator.calculateCost(usage, model);
totalCost += costResult.costs.total;
}
// 如果没有详细的模型数据,使用总量数据和默认模型计算
if (modelStatsMap.size === 0) {
const usage = {
input_tokens: apiKey.usage.total.inputTokens || 0,
output_tokens: apiKey.usage.total.outputTokens || 0,
cache_creation_input_tokens: apiKey.usage.total.cacheCreateTokens || 0,
cache_read_input_tokens: apiKey.usage.total.cacheReadTokens || 0
};
const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022');
totalCost = costResult.costs.total;
}
// 添加格式化的费用到响应数据
apiKey.usage.total.cost = totalCost;
apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost);
}
}
res.json({ success: true, data: apiKeys });
} catch (error) {
logger.error('❌ Failed to get API keys:', error);
@@ -112,7 +183,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params;
const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels } = req.body;
const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, expiresAt } = req.body;
// 只允许更新指定字段
const updates = {};
@@ -178,6 +249,21 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
updates.restrictedModels = restrictedModels;
}
// 处理过期时间字段
if (expiresAt !== undefined) {
if (expiresAt === null) {
// null 表示永不过期
updates.expiresAt = null;
} else {
// 验证日期格式
const expireDate = new Date(expiresAt);
if (isNaN(expireDate.getTime())) {
return res.status(400).json({ error: 'Invalid expiration date format' });
}
updates.expiresAt = expiresAt;
}
}
await apiKeyService.updateApiKey(keyId, updates);
logger.success(`📝 Admin updated API key: ${keyId}`);
@@ -582,8 +668,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
redis.getSystemAverages()
]);
// 计算使用统计(包含cache tokens
const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0);
// 计算使用统计(统一使用allTokens
const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.allTokens || 0), 0);
const totalRequestsUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0);
const totalInputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.inputTokens || 0), 0);
const totalOutputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.outputTokens || 0), 0);

View File

@@ -283,14 +283,17 @@ class ApiKeyService {
let cleanedCount = 0;
for (const key of apiKeys) {
if (key.expiresAt && new Date(key.expiresAt) < now) {
await redis.deleteApiKey(key.id);
// 检查是否已过期且仍处于激活状态
if (key.expiresAt && new Date(key.expiresAt) < now && key.isActive === 'true') {
// 将过期的 API Key 标记为禁用状态,而不是直接删除
await this.updateApiKey(key.id, { isActive: false });
logger.info(`🔒 API Key ${key.id} (${key.name}) has expired and been disabled`);
cleanedCount++;
}
}
if (cleanedCount > 0) {
logger.success(`🧹 Cleaned up ${cleanedCount} expired API keys`);
logger.success(`🧹 Disabled ${cleanedCount} expired API keys`);
}
return cleanedCount;

View File

@@ -125,7 +125,10 @@ const app = createApp({
permissions: 'all', // 'claude', 'gemini', 'all'
enableModelRestriction: false,
restrictedModels: [],
modelInput: ''
modelInput: '',
expireDuration: '', // 过期时长选择
customExpireDate: '', // 自定义过期日期
expiresAt: null // 实际的过期时间戳
},
apiKeyModelStats: {}, // 存储每个key的模型统计数据
expandedApiKeys: {}, // 跟踪展开的API Keys
@@ -155,6 +158,18 @@ const app = createApp({
showFullKey: false
},
// API Key续期
showRenewApiKeyModal: false,
renewApiKeyLoading: false,
renewApiKeyForm: {
id: '',
name: '',
currentExpiresAt: null,
renewDuration: '30d',
customExpireDate: '',
newExpiresAt: null
},
// 编辑API Key
showEditApiKeyModal: false,
editApiKeyLoading: false,
@@ -284,6 +299,13 @@ const app = createApp({
return this.accounts.filter(account =>
account.accountType === 'dedicated' && account.isActive === true
);
},
// 计算最小日期时间(当前时间)
minDateTime() {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
return now.toISOString().slice(0, 16);
}
},
@@ -527,6 +549,72 @@ const app = createApp({
});
},
// 更新过期时间
updateExpireAt() {
const duration = this.apiKeyForm.expireDuration;
if (!duration) {
this.apiKeyForm.expiresAt = null;
return;
}
if (duration === 'custom') {
// 自定义日期需要用户选择
return;
}
const now = new Date();
const durationMap = {
'1d': 1,
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365
};
const days = durationMap[duration];
if (days) {
const expireDate = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
this.apiKeyForm.expiresAt = expireDate.toISOString();
}
},
// 更新自定义过期时间
updateCustomExpireAt() {
if (this.apiKeyForm.customExpireDate) {
const expireDate = new Date(this.apiKeyForm.customExpireDate);
this.apiKeyForm.expiresAt = expireDate.toISOString();
}
},
// 格式化过期日期
formatExpireDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
},
// 检查 API Key 是否已过期
isApiKeyExpired(expiresAt) {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
},
// 检查 API Key 是否即将过期7天内
isApiKeyExpiringSoon(expiresAt) {
if (!expiresAt) return false;
const expireDate = new Date(expiresAt);
const now = new Date();
const daysUntilExpire = (expireDate - now) / (1000 * 60 * 60 * 24);
return daysUntilExpire > 0 && daysUntilExpire <= 7;
},
// 打开创建账户模态框
openCreateAccountModal() {
console.log('Opening Account modal...');
@@ -1784,7 +1872,8 @@ const app = createApp({
geminiAccountId: this.apiKeyForm.geminiAccountId || null,
permissions: this.apiKeyForm.permissions || 'all',
enableModelRestriction: this.apiKeyForm.enableModelRestriction,
restrictedModels: this.apiKeyForm.restrictedModels
restrictedModels: this.apiKeyForm.restrictedModels,
expiresAt: this.apiKeyForm.expiresAt
})
});
@@ -1805,7 +1894,23 @@ const app = createApp({
// 关闭创建弹窗并清理表单
this.showCreateApiKeyModal = false;
this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '', rateLimitWindow: '', rateLimitRequests: '', claudeAccountId: '', enableModelRestriction: false, restrictedModels: [], modelInput: '' };
this.apiKeyForm = {
name: '',
tokenLimit: '',
description: '',
concurrencyLimit: '',
rateLimitWindow: '',
rateLimitRequests: '',
claudeAccountId: '',
geminiAccountId: '',
permissions: 'all',
enableModelRestriction: false,
restrictedModels: [],
modelInput: '',
expireDuration: '',
customExpireDate: '',
expiresAt: null
};
// 重新加载API Keys列表
await this.loadApiKeys();
@@ -1851,6 +1956,111 @@ const app = createApp({
}
},
// 打开续期弹窗
openRenewApiKeyModal(key) {
this.renewApiKeyForm = {
id: key.id,
name: key.name,
currentExpiresAt: key.expiresAt,
renewDuration: '30d',
customExpireDate: '',
newExpiresAt: null
};
this.showRenewApiKeyModal = true;
// 立即计算新的过期时间
this.updateRenewExpireAt();
},
// 关闭续期弹窗
closeRenewApiKeyModal() {
this.showRenewApiKeyModal = false;
this.renewApiKeyForm = {
id: '',
name: '',
currentExpiresAt: null,
renewDuration: '30d',
customExpireDate: '',
newExpiresAt: null
};
},
// 更新续期过期时间
updateRenewExpireAt() {
const duration = this.renewApiKeyForm.renewDuration;
if (duration === 'permanent') {
this.renewApiKeyForm.newExpiresAt = null;
return;
}
if (duration === 'custom') {
// 自定义日期需要用户选择
return;
}
// 计算新的过期时间
const baseTime = this.renewApiKeyForm.currentExpiresAt
? new Date(this.renewApiKeyForm.currentExpiresAt)
: new Date();
// 如果当前已过期,从现在开始计算
if (baseTime < new Date()) {
baseTime.setTime(new Date().getTime());
}
const durationMap = {
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365
};
const days = durationMap[duration];
if (days) {
const expireDate = new Date(baseTime.getTime() + days * 24 * 60 * 60 * 1000);
this.renewApiKeyForm.newExpiresAt = expireDate.toISOString();
}
},
// 更新自定义续期时间
updateCustomRenewExpireAt() {
if (this.renewApiKeyForm.customExpireDate) {
const expireDate = new Date(this.renewApiKeyForm.customExpireDate);
this.renewApiKeyForm.newExpiresAt = expireDate.toISOString();
}
},
// 执行续期操作
async renewApiKey() {
this.renewApiKeyLoading = true;
try {
const data = await this.apiRequest('/admin/api-keys/' + this.renewApiKeyForm.id, {
method: 'PUT',
body: JSON.stringify({
expiresAt: this.renewApiKeyForm.newExpiresAt
})
});
if (!data) {
return;
}
if (data.success) {
this.showToast('API Key 续期成功', 'success');
this.closeRenewApiKeyModal();
await this.loadApiKeys();
} else {
this.showToast(data.message || '续期失败', 'error');
}
} catch (error) {
console.error('Error renewing API key:', error);
this.showToast('续期失败,请检查网络连接', 'error');
} finally {
this.renewApiKeyLoading = false;
}
},
openEditApiKeyModal(key) {
this.editApiKeyForm = {
id: key.id,
@@ -2889,23 +3099,13 @@ const app = createApp({
calculateApiKeyCost(usage) {
if (!usage || !usage.total) return '$0.000000';
// 使用通用模型价格估算
const totalInputTokens = usage.total.inputTokens || 0;
const totalOutputTokens = usage.total.outputTokens || 0;
const totalCacheCreateTokens = usage.total.cacheCreateTokens || 0;
const totalCacheReadTokens = usage.total.cacheReadTokens || 0;
// 使用后端返回的准确费用数据
if (usage.total.formattedCost) {
return usage.total.formattedCost;
}
// 简单估算使用Claude 3.5 Sonnet价格
const inputCost = (totalInputTokens / 1000000) * 3.00;
const outputCost = (totalOutputTokens / 1000000) * 15.00;
const cacheCreateCost = (totalCacheCreateTokens / 1000000) * 3.75;
const cacheReadCost = (totalCacheReadTokens / 1000000) * 0.30;
const totalCost = inputCost + outputCost + cacheCreateCost + cacheReadCost;
if (totalCost < 0.000001) return '$0.000000';
if (totalCost < 0.01) return '$' + totalCost.toFixed(6);
return '$' + totalCost.toFixed(4);
// 如果没有后端费用数据,返回默认值
return '$0.000000';
},
// 初始化日期筛选器

View File

@@ -568,6 +568,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>
</tr>
</thead>
@@ -654,6 +655,11 @@
<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>
</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 -->
<div class="flex justify-between text-xs text-blue-600">
<span>RPM: {{ (key.usage && key.usage.averages && key.usage.averages.rpm) || 0 }}</span>
@@ -678,6 +684,25 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ new Date(key.createdAt).toLocaleDateString() }}
</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">
<div class="flex gap-2">
<button
@@ -686,6 +711,13 @@
>
<i class="fas fa-edit mr-1"></i>编辑
</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
@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"
@@ -2000,6 +2032,36 @@
></textarea>
</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>
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
<div class="flex gap-4">
@@ -2410,6 +2472,92 @@
</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 展示弹窗 -->
<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">