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:
shaw
2025-07-16 09:45:47 +08:00
parent efa7048018
commit 12b41ceb25
9 changed files with 654 additions and 15 deletions

View File

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

View File

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

View File

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

View File

@@ -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)) {