const { rateLimit } = require('express-rate-limit'); const { logger } = require('../utils/logger'); function parsePositiveInteger(value, fallback) { const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; } function createGlobalApiLimiter(options = {}) { const windowMs = parsePositiveInteger(options.windowMs ?? process.env.API_RATE_LIMIT_WINDOW_MS, 60_000); const limit = parsePositiveInteger(options.limit ?? process.env.API_RATE_LIMIT_MAX, 600); return rateLimit({ windowMs, limit, standardHeaders: 'draft-8', legacyHeaders: false, skip: (req) => req.method === 'OPTIONS' || req.path === '/health', handler: (req, res) => { (req.log || logger).warn({ method: req.method, path: req.path }, 'global API rate limit exceeded'); res.status(429).json({ success: false, message: '请求过于频繁,请稍后再试', requestId: req.id }); } }); } class RateLimiter { constructor(options = {}) { this.maxAttempts = options.maxAttempts || 5; this.windowMs = options.windowMs || 15 * 60 * 1000; this.blockDuration = options.blockDuration || 30 * 60 * 1000; this.attempts = new Map(); this.blockedKeys = new Map(); this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000); this.cleanupInterval.unref?.(); } getClientKey(req) { return req.ip || req.socket?.remoteAddress || 'unknown'; } isBlocked(key) { const blockInfo = this.blockedKeys.get(key); if (!blockInfo) return false; if (Date.now() > blockInfo.expiresAt) { this.blockedKeys.delete(key); this.attempts.delete(key); return false; } return true; } recordFailure(key) { const now = Date.now(); if (this.isBlocked(key)) { const blockInfo = this.blockedKeys.get(key); return { blocked: true, remainingAttempts: 0, resetTime: blockInfo.expiresAt, waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60_000), needCaptcha: true }; } let attemptInfo = this.attempts.get(key); if (!attemptInfo || now > attemptInfo.windowEnd) { attemptInfo = { count: 0, windowEnd: now + this.windowMs, firstAttempt: now }; } attemptInfo.count += 1; this.attempts.set(key, attemptInfo); if (attemptInfo.count >= this.maxAttempts) { const blockExpiresAt = now + this.blockDuration; this.blockedKeys.set(key, { expiresAt: blockExpiresAt, blockedAt: now }); logger.warn({ key, attempts: attemptInfo.count, blockDuration: this.blockDuration }, 'failure limiter blocked key'); return { blocked: true, remainingAttempts: 0, resetTime: blockExpiresAt, waitMinutes: Math.ceil(this.blockDuration / 60_000), needCaptcha: true }; } return { blocked: false, remainingAttempts: this.maxAttempts - attemptInfo.count, resetTime: attemptInfo.windowEnd, waitMinutes: 0, needCaptcha: attemptInfo.count >= 2 }; } getFailureCount(key) { const attemptInfo = this.attempts.get(key); if (!attemptInfo || Date.now() > attemptInfo.windowEnd) return 0; return attemptInfo.count; } recordSuccess(key) { this.attempts.delete(key); this.blockedKeys.delete(key); } cleanup() { const now = Date.now(); let cleanedAttempts = 0; let cleanedBlocks = 0; for (const [key, info] of this.attempts.entries()) { if (now > info.windowEnd) { this.attempts.delete(key); cleanedAttempts += 1; } } for (const [key, info] of this.blockedKeys.entries()) { if (now > info.expiresAt) { this.blockedKeys.delete(key); cleanedBlocks += 1; } } if (cleanedAttempts || cleanedBlocks) { logger.info({ cleanedAttempts, cleanedBlocks }, 'failure limiter cleanup completed'); } } getStats() { return { activeAttempts: this.attempts.size, blockedKeys: this.blockedKeys.size }; } destroy() { if (this.cleanupInterval) clearInterval(this.cleanupInterval); } } module.exports = { RateLimiter, createGlobalApiLimiter };