mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 添加API Key并发控制和编辑功能
- 新增API Key并发控制功能 - 创建API Key时可设置并发限制(0为不限制) - 在认证中间件中实现并发检查 - 使用Redis原子操作确保计数准确 - 添加自动清理机制处理异常情况 - 新增API Key编辑功能 - 支持修改Token限制和并发限制 - 前端添加编辑按钮和模态框 - 后端限制只能修改指定字段 - 其他改进 - 添加test-concurrency.js测试脚本 - 添加详细的功能说明文档 - 所有代码通过ESLint检查 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
88
API_KEY_EDIT_FEATURE.md
Normal file
88
API_KEY_EDIT_FEATURE.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# API Key 编辑功能
|
||||
|
||||
## 功能说明
|
||||
|
||||
现在支持在管理后台编辑已创建的 API Key,可以修改以下参数:
|
||||
|
||||
- **Token 限制**:设置该 API Key 的最大 token 使用量
|
||||
- **并发限制**:设置该 API Key 可同时处理的最大请求数
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. **进入管理后台**
|
||||
- 登录管理后台
|
||||
- 进入 "API Keys" 标签页
|
||||
|
||||
2. **编辑 API Key**
|
||||
- 在 API Key 列表中找到要编辑的项
|
||||
- 点击 "编辑" 按钮(蓝色铅笔图标)
|
||||
- 在弹出的编辑框中修改参数:
|
||||
- Token 限制:输入数字,0 或留空表示无限制
|
||||
- 并发限制:输入数字,0 或留空表示无限制
|
||||
- 点击 "保存修改" 完成编辑
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **不可修改的字段**
|
||||
- API Key 名称不可修改(显示为灰色禁用状态)
|
||||
- API Key 的密钥值不可修改
|
||||
- 描述信息不可修改
|
||||
|
||||
2. **参数验证**
|
||||
- Token 限制和并发限制必须为非负整数
|
||||
- 0 表示无限制
|
||||
- 留空也表示无限制
|
||||
|
||||
3. **即时生效**
|
||||
- 修改保存后立即生效
|
||||
- 正在进行的请求不受影响
|
||||
- 新的请求将使用更新后的限制
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 前端部分
|
||||
- 在 API Key 列表添加编辑按钮
|
||||
- <20><><EFBFBD>建编辑模态框,仅显示可编辑字段
|
||||
- 使用 PUT 请求更新 API Key
|
||||
|
||||
### 后端部分
|
||||
- 更新路由只接受 tokenLimit 和 concurrencyLimit 参数
|
||||
- 严格验证参数类型和范围
|
||||
- 使用现有的 apiKeyService.updateApiKey 方法
|
||||
|
||||
### 安全性
|
||||
- 需要管理员认证才能编辑
|
||||
- 只允许修改限制相关参数
|
||||
- 不会暴露敏感信息(如 API Key 值)
|
||||
|
||||
## API 接口
|
||||
|
||||
```
|
||||
PUT /admin/api-keys/:keyId
|
||||
Authorization: Bearer <admin_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"tokenLimit": 1000000,
|
||||
"concurrencyLimit": 5
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "API key updated successfully"
|
||||
}
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 为什么不能修改 API Key 的名称?**
|
||||
A: 为了保持数据一致性和避免混淆,API Key 创建后名称不可修改。
|
||||
|
||||
**Q: 修改限制后,已经超过限制的请求会怎样?**
|
||||
A: 已经在处理中的请求不受影响,新的请求将受到新限制的约束。
|
||||
|
||||
**Q: 可以通过 CLI 或 API 修改吗?**
|
||||
A: 目前仅支持通过管理后台修改,后续可能会添加 CLI 支持。
|
||||
94
CONCURRENCY_CONTROL.md
Normal file
94
CONCURRENCY_CONTROL.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# API Key 并发控制功能
|
||||
|
||||
## 功能概述
|
||||
|
||||
Claude Relay Service 现在支持为每个 API Key 设置并发请求限制。这个功能可以帮助:
|
||||
|
||||
- 防止单个 API Key 占用过多资源
|
||||
- 控制对 Claude API 的并发访问
|
||||
- 为不同用户/应用分配不同的并发配额
|
||||
- 保护服务稳定性
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 创建带并发限制的 API Key
|
||||
|
||||
在管理后台创建 API Key 时,可以设置"并发限制"字段:
|
||||
|
||||
- **0 或留空**:无并发限制(默认)
|
||||
- **正整数**:限制同时处理的最大请求数
|
||||
|
||||
例如:设置为 5,则该 API Key 最多同时处理 5 个请求。
|
||||
|
||||
### 2. 并发控制行为
|
||||
|
||||
当请求超过并发限制时:
|
||||
|
||||
- HTTP 状态码:`429 Too Many Requests`
|
||||
- 响应内容:
|
||||
```json
|
||||
{
|
||||
"error": "Concurrency limit exceeded",
|
||||
"message": "Too many concurrent requests. Limit: 5 concurrent requests",
|
||||
"currentConcurrency": 5,
|
||||
"concurrencyLimit": 5
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 查看并发限制
|
||||
|
||||
在管理后台的 API Keys 列表中,每个 Key 都会显示其并发限制设置:
|
||||
|
||||
- 显示为具体数字(如 "5")表示有限制
|
||||
- 显示为 "无限制" 表示没有并发限制
|
||||
|
||||
## 技术实现
|
||||
|
||||
### Redis 键结构
|
||||
|
||||
并发计数器存储在 Redis 中:
|
||||
- 键名:`concurrency:{apiKeyId}`
|
||||
- 过期时间:5分钟(防止异常情况下计数器不归零)
|
||||
|
||||
### 并发控制流程
|
||||
|
||||
1. 请求到达时,增加并发计数
|
||||
2. 如果超过限制,立即拒绝并减少计数
|
||||
3. 请求处理完成后,自动减少计数
|
||||
4. 支持正常完成和异常中断的清理
|
||||
|
||||
## 测试并发控制
|
||||
|
||||
使用提供的测试脚本:
|
||||
|
||||
```bash
|
||||
# 测试 10 个并发请求
|
||||
node test-concurrency.js cr_your_api_key_here 10
|
||||
|
||||
# 使用自定义服务器地址
|
||||
SERVER_URL=http://your-server:3000 node test-concurrency.js cr_your_api_key_here 20
|
||||
```
|
||||
|
||||
测试脚本会:
|
||||
- 同时发送指定数量的请求
|
||||
- 显示每个请求的结果
|
||||
- 统计成功、被限流和错误的请求数
|
||||
- 验证并发控制是否正常工作
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **兼容性**:新功能完全向后兼容,现有 API Key 默认无并发限制
|
||||
2. **性能**:并发控制使用 Redis 原子操作,性能影响极小
|
||||
3. **清理机制**:请求结束时自动清理计数,异常情况有过期时间保护
|
||||
4. **监控**:所有并发限制触发都会记录在日志中
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 并发限制会影响流式响应吗?**
|
||||
A: 不会。并发限制只在请求开始时检查,一旦请求被接受,流式响应会正常进行。
|
||||
|
||||
**Q: 如何修改现有 API Key 的并发限制?**
|
||||
A: 目前需要在管理后台编辑 API Key,后续会支持此功能。
|
||||
|
||||
**Q: 并发计数不准确怎么办?**
|
||||
A: 并发计数器有 5 分钟过期时间,会自动重置。如需立即重置,可以在 Redis 中删除对应的键。
|
||||
@@ -61,13 +61,46 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
res.setHeader('X-RateLimit-Reset', rateLimitResult.resetTime);
|
||||
res.setHeader('X-RateLimit-Policy', `${rateLimitResult.limit};w=60`);
|
||||
|
||||
// 检查并发限制
|
||||
const concurrencyLimit = validation.keyData.concurrencyLimit || 0;
|
||||
if (concurrencyLimit > 0) {
|
||||
const currentConcurrency = await redis.incrConcurrency(validation.keyData.id);
|
||||
|
||||
if (currentConcurrency > concurrencyLimit) {
|
||||
// 如果超过限制,立即减少计数
|
||||
await redis.decrConcurrency(validation.keyData.id);
|
||||
logger.security(`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}`);
|
||||
return res.status(429).json({
|
||||
error: 'Concurrency limit exceeded',
|
||||
message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`,
|
||||
currentConcurrency: currentConcurrency - 1,
|
||||
concurrencyLimit
|
||||
});
|
||||
}
|
||||
|
||||
// 在响应结束时减少并发计数
|
||||
res.on('finish', () => {
|
||||
redis.decrConcurrency(validation.keyData.id).catch(error => {
|
||||
logger.error('Failed to decrement concurrency:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// 在响应错误时也减少并发计数
|
||||
res.on('error', () => {
|
||||
redis.decrConcurrency(validation.keyData.id).catch(error => {
|
||||
logger.error('Failed to decrement concurrency on error:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 将验证信息添加到请求对象(只包含必要信息)
|
||||
req.apiKey = {
|
||||
id: validation.keyData.id,
|
||||
name: validation.keyData.name,
|
||||
tokenLimit: validation.keyData.tokenLimit,
|
||||
requestLimit: validation.keyData.requestLimit,
|
||||
claudeAccountId: validation.keyData.claudeAccountId
|
||||
claudeAccountId: validation.keyData.claudeAccountId,
|
||||
concurrencyLimit: validation.keyData.concurrencyLimit
|
||||
};
|
||||
req.usage = validation.keyData.usage;
|
||||
|
||||
|
||||
@@ -714,6 +714,53 @@ class RedisClient {
|
||||
logger.error('❌ Redis cleanup failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 增加并发计数
|
||||
async incrConcurrency(apiKeyId) {
|
||||
try {
|
||||
const key = `concurrency:${apiKeyId}`;
|
||||
const count = await this.client.incr(key);
|
||||
|
||||
// 设置过期时间为5分钟,防止计数器永远不清零
|
||||
await this.client.expire(key, 300);
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to increment concurrency:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 减少并发计数
|
||||
async decrConcurrency(apiKeyId) {
|
||||
try {
|
||||
const key = `concurrency:${apiKeyId}`;
|
||||
const count = await this.client.decr(key);
|
||||
|
||||
// 如果计数降到0或以下,删除键
|
||||
if (count <= 0) {
|
||||
await this.client.del(key);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to decrement concurrency:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前并发数
|
||||
async getConcurrency(apiKeyId) {
|
||||
try {
|
||||
const key = `concurrency:${apiKeyId}`;
|
||||
const count = await this.client.get(key);
|
||||
return parseInt(count || 0);
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get concurrency:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RedisClient();
|
||||
@@ -32,7 +32,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
tokenLimit,
|
||||
requestLimit,
|
||||
expiresAt,
|
||||
claudeAccountId
|
||||
claudeAccountId,
|
||||
concurrencyLimit
|
||||
} = req.body;
|
||||
|
||||
// 输入验证
|
||||
@@ -56,13 +57,18 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Request limit must be a non-negative integer' });
|
||||
}
|
||||
|
||||
if (concurrencyLimit !== undefined && concurrencyLimit !== null && concurrencyLimit !== '' && (!Number.isInteger(Number(concurrencyLimit)) || Number(concurrencyLimit) < 0)) {
|
||||
return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' });
|
||||
}
|
||||
|
||||
const newKey = await apiKeyService.generateApiKey({
|
||||
name,
|
||||
description,
|
||||
tokenLimit,
|
||||
requestLimit,
|
||||
expiresAt,
|
||||
claudeAccountId
|
||||
claudeAccountId,
|
||||
concurrencyLimit
|
||||
});
|
||||
|
||||
logger.success(`🔑 Admin created new API key: ${name}`);
|
||||
@@ -77,7 +83,24 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const updates = req.body;
|
||||
const { tokenLimit, concurrencyLimit } = req.body;
|
||||
|
||||
// 只允许更新tokenLimit和concurrencyLimit
|
||||
const updates = {};
|
||||
|
||||
if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') {
|
||||
if (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0) {
|
||||
return res.status(400).json({ error: 'Token limit must be a non-negative integer' });
|
||||
}
|
||||
updates.tokenLimit = Number(tokenLimit);
|
||||
}
|
||||
|
||||
if (concurrencyLimit !== undefined && concurrencyLimit !== null && concurrencyLimit !== '') {
|
||||
if (!Number.isInteger(Number(concurrencyLimit)) || Number(concurrencyLimit) < 0) {
|
||||
return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' });
|
||||
}
|
||||
updates.concurrencyLimit = Number(concurrencyLimit);
|
||||
}
|
||||
|
||||
await apiKeyService.updateApiKey(keyId, updates);
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ class ApiKeyService {
|
||||
requestLimit = config.limits.defaultRequestLimit,
|
||||
expiresAt = null,
|
||||
claudeAccountId = null,
|
||||
isActive = true
|
||||
isActive = true,
|
||||
concurrencyLimit = 0
|
||||
} = options;
|
||||
|
||||
// 生成简单的API Key (64字符十六进制)
|
||||
@@ -33,6 +34,7 @@ class ApiKeyService {
|
||||
apiKey: hashedKey,
|
||||
tokenLimit: String(tokenLimit ?? 0),
|
||||
requestLimit: String(requestLimit ?? 0),
|
||||
concurrencyLimit: String(concurrencyLimit ?? 0),
|
||||
isActive: String(isActive),
|
||||
claudeAccountId: claudeAccountId || '',
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -53,6 +55,7 @@ class ApiKeyService {
|
||||
description: keyData.description,
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
requestLimit: parseInt(keyData.requestLimit),
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit),
|
||||
isActive: keyData.isActive === 'true',
|
||||
claudeAccountId: keyData.claudeAccountId,
|
||||
createdAt: keyData.createdAt,
|
||||
@@ -114,6 +117,7 @@ class ApiKeyService {
|
||||
claudeAccountId: keyData.claudeAccountId,
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
requestLimit: parseInt(keyData.requestLimit),
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||
usage
|
||||
}
|
||||
};
|
||||
@@ -133,6 +137,7 @@ class ApiKeyService {
|
||||
key.usage = await redis.getUsageStats(key.id);
|
||||
key.tokenLimit = parseInt(key.tokenLimit);
|
||||
key.requestLimit = parseInt(key.requestLimit);
|
||||
key.concurrencyLimit = parseInt(key.concurrencyLimit || 0);
|
||||
key.isActive = key.isActive === 'true';
|
||||
delete key.apiKey; // 不返回哈希后的key
|
||||
}
|
||||
@@ -153,7 +158,7 @@ class ApiKeyService {
|
||||
}
|
||||
|
||||
// 允许更新的字段
|
||||
const allowedUpdates = ['name', 'description', 'tokenLimit', 'requestLimit', 'isActive', 'claudeAccountId', 'expiresAt'];
|
||||
const allowedUpdates = ['name', 'description', 'tokenLimit', 'requestLimit', 'concurrencyLimit', 'isActive', 'claudeAccountId', 'expiresAt'];
|
||||
const updatedData = { ...keyData };
|
||||
|
||||
for (const [field, value] of Object.entries(updates)) {
|
||||
|
||||
184
test-concurrency.js
Normal file
184
test-concurrency.js
Normal file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// 并发控制功能测试脚本
|
||||
// 用法: node test-concurrency.js <API_KEY> <并发数>
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
const API_KEY = process.argv[2];
|
||||
const CONCURRENCY = parseInt(process.argv[3]) || 10;
|
||||
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000';
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error('请提供API Key: node test-concurrency.js <API_KEY> <并发数>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 解析URL
|
||||
const url = new URL(SERVER_URL);
|
||||
const protocol = url.protocol === 'https:' ? https : http;
|
||||
|
||||
// 发送单个请求
|
||||
function sendRequest(index) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: '/api/v1/messages',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': API_KEY
|
||||
}
|
||||
};
|
||||
|
||||
const req = protocol.request(options, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (res.statusCode === 429) {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
resolve({
|
||||
index,
|
||||
status: res.statusCode,
|
||||
error: response.error,
|
||||
message: response.message,
|
||||
concurrencyLimit: response.concurrencyLimit,
|
||||
currentConcurrency: response.currentConcurrency,
|
||||
duration
|
||||
});
|
||||
} catch (e) {
|
||||
resolve({
|
||||
index,
|
||||
status: res.statusCode,
|
||||
error: 'Rate limit exceeded',
|
||||
message: data,
|
||||
duration
|
||||
});
|
||||
}
|
||||
} else if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve({
|
||||
index,
|
||||
status: res.statusCode,
|
||||
success: true,
|
||||
duration
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
index,
|
||||
status: res.statusCode,
|
||||
error: 'Request failed',
|
||||
message: data,
|
||||
duration
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject({
|
||||
index,
|
||||
error: error.message,
|
||||
duration: Date.now() - startTime
|
||||
});
|
||||
});
|
||||
|
||||
// 发送测试请求
|
||||
const testData = JSON.stringify({
|
||||
model: 'claude-3-haiku-20240307',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `测试并发请求 #${index}`
|
||||
}
|
||||
],
|
||||
max_tokens: 10,
|
||||
stream: false
|
||||
});
|
||||
|
||||
req.write(testData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 运行并发测试
|
||||
async function runConcurrencyTest() {
|
||||
console.log(`\n🧪 开始并发控制测试`);
|
||||
console.log(`<EFBFBD><EFBFBD> 服务器: ${SERVER_URL}`);
|
||||
console.log(`🔑 API Key: ${API_KEY.substring(0, 10)}...`);
|
||||
console.log(`🔄 并发请求数: ${CONCURRENCY}`);
|
||||
console.log(`⏰ 开始时间: ${new Date().toISOString()}\n`);
|
||||
|
||||
// 创建并发请求
|
||||
const promises = [];
|
||||
for (let i = 1; i <= CONCURRENCY; i++) {
|
||||
promises.push(sendRequest(i));
|
||||
}
|
||||
|
||||
// 等待所有请求完成
|
||||
try {
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// 统计结果
|
||||
let successCount = 0;
|
||||
let rateLimitCount = 0;
|
||||
let errorCount = 0;
|
||||
let concurrencyLimit = null;
|
||||
let maxConcurrency = 0;
|
||||
|
||||
console.log('📊 请求结果:\n');
|
||||
|
||||
results.forEach(result => {
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
console.log(`✅ 请求 #${result.index}: 成功 (${result.duration}ms)`);
|
||||
} else if (result.status === 429) {
|
||||
rateLimitCount++;
|
||||
if (result.concurrencyLimit) {
|
||||
concurrencyLimit = result.concurrencyLimit;
|
||||
if (result.currentConcurrency > maxConcurrency) {
|
||||
maxConcurrency = result.currentConcurrency;
|
||||
}
|
||||
}
|
||||
console.log(`🚫 请求 #${result.index}: ${result.message} (${result.duration}ms)`);
|
||||
} else {
|
||||
errorCount++;
|
||||
console.log(`❌ 请求 #${result.index}: ${result.error} - ${result.message} (${result.duration}ms)`);
|
||||
}
|
||||
});
|
||||
|
||||
// 打印统计信息
|
||||
console.log('\n📈 测试统计:');
|
||||
console.log(`✅ 成功请求: ${successCount}`);
|
||||
console.log(`🚫 被限流请求: ${rateLimitCount}`);
|
||||
console.log(`❌ 错误请求: ${errorCount}`);
|
||||
|
||||
if (concurrencyLimit !== null) {
|
||||
console.log(`\n🔐 并发限制信息:`);
|
||||
console.log(`📏 配置的并发限制: ${concurrencyLimit}`);
|
||||
console.log(`📊 检测到的最大并发数: ${maxConcurrency}`);
|
||||
|
||||
if (successCount === concurrencyLimit && rateLimitCount === CONCURRENCY - concurrencyLimit) {
|
||||
console.log(`\n✅ 并发控制工作正常!成功限制了并发数为 ${concurrencyLimit}`);
|
||||
}
|
||||
} else if (successCount === CONCURRENCY) {
|
||||
console.log(`\n✅ 所有请求都成功了,该 API Key 没有并发限制或限制大于 ${CONCURRENCY}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ 测试失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runConcurrencyTest().catch(console.error);
|
||||
@@ -102,7 +102,8 @@ const app = createApp({
|
||||
apiKeyForm: {
|
||||
name: '',
|
||||
tokenLimit: '',
|
||||
description: ''
|
||||
description: '',
|
||||
concurrencyLimit: ''
|
||||
},
|
||||
apiKeyModelStats: {}, // 存储每个key的模型统计数据
|
||||
expandedApiKeys: {}, // 跟踪展开的API Keys
|
||||
@@ -132,6 +133,16 @@ const app = createApp({
|
||||
showFullKey: false
|
||||
},
|
||||
|
||||
// 编辑API Key
|
||||
showEditApiKeyModal: false,
|
||||
editApiKeyLoading: false,
|
||||
editApiKeyForm: {
|
||||
id: '',
|
||||
name: '',
|
||||
tokenLimit: '',
|
||||
concurrencyLimit: ''
|
||||
},
|
||||
|
||||
// 账户
|
||||
accounts: [],
|
||||
accountsLoading: false,
|
||||
@@ -966,7 +977,8 @@ const app = createApp({
|
||||
body: JSON.stringify({
|
||||
name: this.apiKeyForm.name,
|
||||
tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.trim() ? parseInt(this.apiKeyForm.tokenLimit) : null,
|
||||
description: this.apiKeyForm.description || ''
|
||||
description: this.apiKeyForm.description || '',
|
||||
concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0
|
||||
})
|
||||
});
|
||||
|
||||
@@ -984,7 +996,7 @@ const app = createApp({
|
||||
|
||||
// 关闭创建弹窗并清理表单
|
||||
this.showCreateApiKeyModal = false;
|
||||
this.apiKeyForm = { name: '', tokenLimit: '', description: '' };
|
||||
this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '' };
|
||||
|
||||
// 重新加载API Keys列表
|
||||
await this.loadApiKeys();
|
||||
@@ -1022,6 +1034,58 @@ const app = createApp({
|
||||
}
|
||||
},
|
||||
|
||||
openEditApiKeyModal(key) {
|
||||
this.editApiKeyForm = {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
tokenLimit: key.tokenLimit || '',
|
||||
concurrencyLimit: key.concurrencyLimit || ''
|
||||
};
|
||||
this.showEditApiKeyModal = true;
|
||||
},
|
||||
|
||||
closeEditApiKeyModal() {
|
||||
this.showEditApiKeyModal = false;
|
||||
this.editApiKeyForm = {
|
||||
id: '',
|
||||
name: '',
|
||||
tokenLimit: '',
|
||||
concurrencyLimit: ''
|
||||
};
|
||||
},
|
||||
|
||||
async updateApiKey() {
|
||||
this.editApiKeyLoading = true;
|
||||
try {
|
||||
const response = await fetch('/admin/api-keys/' + this.editApiKeyForm.id, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + this.authToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tokenLimit: this.editApiKeyForm.tokenLimit && this.editApiKeyForm.tokenLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.tokenLimit) : 0,
|
||||
concurrencyLimit: this.editApiKeyForm.concurrencyLimit && this.editApiKeyForm.concurrencyLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.concurrencyLimit) : 0
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.showToast('API Key 更新成功', 'success', '更新成功');
|
||||
this.closeEditApiKeyModal();
|
||||
await this.loadApiKeys();
|
||||
} else {
|
||||
this.showToast(data.message || '更新失败', 'error', '更新失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating API key:', error);
|
||||
this.showToast('更新失败,请检查网络连接', 'error', '网络错误');
|
||||
} finally {
|
||||
this.editApiKeyLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteAccount(accountId) {
|
||||
if (!confirm('确定要删除这个 Claude 账户吗?')) return;
|
||||
|
||||
|
||||
@@ -425,6 +425,11 @@
|
||||
<span class="text-gray-600">费用:</span>
|
||||
<span class="font-medium text-green-600">{{ calculateApiKeyCost(key.usage) }}</span>
|
||||
</div>
|
||||
<!-- 并发限制 -->
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">并发限制:</span>
|
||||
<span class="font-medium text-purple-600">{{ key.concurrencyLimit > 0 ? key.concurrencyLimit : '无限制' }}</span>
|
||||
</div>
|
||||
<!-- 输入/输出Token -->
|
||||
<div class="flex justify-between text-xs text-gray-500">
|
||||
<span>输入: {{ formatNumber((key.usage && key.usage.total && key.usage.total.inputTokens) || 0) }}</span>
|
||||
@@ -455,12 +460,20 @@
|
||||
{{ new Date(key.createdAt).toLocaleDateString() }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="openEditApiKeyModal(key)"
|
||||
class="text-blue-600 hover:text-blue-900 font-medium hover:bg-blue-50 px-3 py-1 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-edit 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"
|
||||
>
|
||||
<i class="fas fa-trash mr-1"></i>删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1664,6 +1677,18 @@
|
||||
<p class="text-xs text-gray-500 mt-2">设置此 API Key 的最大 token 使用量</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制 (可选)</label>
|
||||
<input
|
||||
v-model="apiKeyForm.concurrencyLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数,0 或留空表示无限制</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">备注 (可选)</label>
|
||||
<textarea
|
||||
@@ -1696,6 +1721,82 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑 API Key 模态框 -->
|
||||
<div v-if="showEditApiKeyModal" 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">
|
||||
<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-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-edit text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">编辑 API Key</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="closeEditApiKeyModal"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="updateApiKey" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">名称</label>
|
||||
<input
|
||||
:value="editApiKeyForm.name"
|
||||
type="text"
|
||||
disabled
|
||||
class="form-input w-full bg-gray-100 cursor-not-allowed"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">名称不可修改</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">Token 限制</label>
|
||||
<input
|
||||
v-model="editApiKeyForm.tokenLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">设置此 API Key 的最大 token 使用量,0 或留空表示无限制</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制</label>
|
||||
<input
|
||||
v-model="editApiKeyForm.concurrencyLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数,0 或留空表示无限制</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeEditApiKeyModal"
|
||||
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="submit"
|
||||
:disabled="editApiKeyLoading"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="editApiKeyLoading" class="loading-spinner mr-2"></div>
|
||||
<i v-else class="fas fa-save mr-2"></i>
|
||||
{{ editApiKeyLoading ? '保存中...' : '保存修改' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user