feat: 添加API Key时间窗口限流功能并移除累计总量限制

- 新增时间窗口限流功能,支持按分钟设置时间窗口
- 支持在时间窗口内限制请求次数和Token使用量
- 移除原有的累计总量限制,只保留时间窗口限制
- Token统计包含所有4种类型:输入、输出、缓存创建、缓存读取
- 前端UI优化,明确显示限流参数的作用范围
- 限流触发时提供友好的错误提示和重置时间

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-07-20 15:58:00 +08:00
parent 0aa986a0d8
commit 088ce266ba
7 changed files with 224 additions and 16 deletions

View File

@@ -33,6 +33,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
expiresAt,
claudeAccountId,
concurrencyLimit,
rateLimitWindow,
rateLimitRequests,
enableModelRestriction,
restrictedModels
} = req.body;
@@ -58,6 +60,14 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
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' });
}
if (rateLimitWindow !== undefined && rateLimitWindow !== null && rateLimitWindow !== '' && (!Number.isInteger(Number(rateLimitWindow)) || Number(rateLimitWindow) < 1)) {
return res.status(400).json({ error: 'Rate limit window must be a positive integer (minutes)' });
}
if (rateLimitRequests !== undefined && rateLimitRequests !== null && rateLimitRequests !== '' && (!Number.isInteger(Number(rateLimitRequests)) || Number(rateLimitRequests) < 1)) {
return res.status(400).json({ error: 'Rate limit requests must be a positive integer' });
}
// 验证模型限制字段
if (enableModelRestriction !== undefined && typeof enableModelRestriction !== 'boolean') {
@@ -75,6 +85,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
expiresAt,
claudeAccountId,
concurrencyLimit,
rateLimitWindow,
rateLimitRequests,
enableModelRestriction,
restrictedModels
});
@@ -91,7 +103,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, claudeAccountId, enableModelRestriction, restrictedModels } = req.body;
const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, enableModelRestriction, restrictedModels } = req.body;
// 只允许更新指定字段
const updates = {};
@@ -109,6 +121,20 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
}
updates.concurrencyLimit = Number(concurrencyLimit);
}
if (rateLimitWindow !== undefined && rateLimitWindow !== null && rateLimitWindow !== '') {
if (!Number.isInteger(Number(rateLimitWindow)) || Number(rateLimitWindow) < 0) {
return res.status(400).json({ error: 'Rate limit window must be a non-negative integer (minutes)' });
}
updates.rateLimitWindow = Number(rateLimitWindow);
}
if (rateLimitRequests !== undefined && rateLimitRequests !== null && rateLimitRequests !== '') {
if (!Number.isInteger(Number(rateLimitRequests)) || Number(rateLimitRequests) < 0) {
return res.status(400).json({ error: 'Rate limit requests must be a non-negative integer' });
}
updates.rateLimitRequests = Number(rateLimitRequests);
}
if (claudeAccountId !== undefined) {
// 空字符串表示解绑null或空字符串都设置为空字符串

View File

@@ -3,6 +3,7 @@ const claudeRelayService = require('../services/claudeRelayService');
const apiKeyService = require('../services/apiKeyService');
const { authenticateApiKey } = require('../middleware/auth');
const logger = require('../utils/logger');
const redis = require('../models/redis');
const router = express.Router();
@@ -66,6 +67,15 @@ router.post('/v1/messages', authenticateApiKey, async (req, res) => {
logger.error('❌ Failed to record stream usage:', error);
});
// 更新时间窗口内的token计数
if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens).catch(error => {
logger.error('❌ Failed to update rate limit token count:', error);
});
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`);
}
usageDataCaptured = true;
logger.api(`📊 Stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens`);
} else {
@@ -122,6 +132,13 @@ router.post('/v1/messages', authenticateApiKey, async (req, res) => {
// 记录真实的token使用量包含模型信息和所有4种token
await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
// 更新时间窗口内的token计数
if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
await redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens);
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`);
}
usageRecorded = true;
logger.api(`📊 Non-stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens`);
} else {