'use strict'; /** * API 限流中间件 * 基于 token bucket 的简单限流实现,适用于考试并发场景 * 针对不同端点使用不同限流策略: * - 通用 API: 100次/分钟 * - 登录/激活: 10次/分钟(防暴力破解) * - 考试提交: 30次/分钟(高峰期保护) * - 管理后台: 200次/分钟 */ // 请求计数器存储(内存实现,生产环境建议换 Redis) const counters = new Map(); // 每60秒自动清理过期计数器 setInterval(() => { const now = Date.now(); for (const [key, entry] of counters) { if (now - entry.resetTime > 60000) { counters.delete(key); } } }, 60000); /** * 限流配置预设 */ const LIMITS = { default: { max: 100, window: 60 }, // 通用: 100次/分钟 auth: { max: 10, window: 60 }, // 登录/激活: 10次/分钟 exam: { max: 30, window: 60 }, // 考试提交: 30次/分钟 upload: { max: 20, window: 60 }, // 文件上传: 20次/分钟 admin: { max: 200, window: 60 }, // 管理后台: 200次/分钟 }; /** * 获取客户端标识(优先用用户ID,其次IP) */ function getClientId(ctx) { if (ctx.state.currentUser && ctx.state.currentUser.uid) { return `user:${ctx.state.currentUser.uid}`; } return `ip:${ctx.ip}`; } /** * 根据路由自动判断限流等级 */ function getLimitLevel(ctx) { const path = ctx.path; if (path.startsWith('/api/auth/login') || path.startsWith('/api/auth/activate') || path.startsWith('/api/auth/forgot-password') || path.startsWith('/api/auth/admin-login')) { return 'auth'; } if (path.startsWith('/api/exam')) { return 'exam'; } if (path.startsWith('/api/upload')) { return 'upload'; } if (path.startsWith('/api/admin')) { return 'admin'; } return 'default'; } module.exports = (options = {}) => { const level = options.level || 'default'; const limit = options.max || LIMITS[level]?.max || LIMITS.default.max; const windowSec = options.window || LIMITS[level]?.window || LIMITS.default.window; return async function rateLimit(ctx, next) { // 自动检测限流级别 const effectiveLevel = options.level || getLimitLevel(ctx); const effectiveLimit = options.max || LIMITS[effectiveLevel]?.max || LIMITS.default.max; const effectiveWindow = options.window || LIMITS[effectiveLevel]?.window || LIMITS.default.window; const clientId = getClientId(ctx); const routeKey = `${effectiveLevel}:${clientId}:${ctx.path}`; const now = Date.now(); let entry = counters.get(routeKey); // 初始化或重置过期计数器 if (!entry || now - entry.resetTime > effectiveWindow * 1000) { entry = { count: 0, resetTime: now + effectiveWindow * 1000 }; counters.set(routeKey, entry); } entry.count++; // 设置响应头告知限流状态 const remaining = Math.max(0, effectiveLimit - entry.count); const resetTime = new Date(entry.resetTime).toISOString(); ctx.set('X-RateLimit-Limit', effectiveLimit); ctx.set('X-RateLimit-Remaining', remaining); ctx.set('X-RateLimit-Reset', resetTime); // 超限拒绝 if (entry.count > effectiveLimit) { ctx.status = 429; ctx.body = { code: 429, message: `请求过于频繁,请${effectiveWindow}秒后重试。限流级别: ${effectiveLevel},上限: ${effectiveLimit}次/${effectiveWindow}秒`, data: { retryAfter: effectiveWindow, limit: effectiveLimit, remaining: 0, resetTime, }, }; ctx.logger.warn(`[RateLimit] ${effectiveLevel} 限流触发: ${clientId} -> ${ctx.path} (${entry.count}/${effectiveLimit})`); return; } ctx.logger.debug(`[RateLimit] ${effectiveLevel}: ${clientId} -> ${ctx.path} (${entry.count}/${effectiveLimit})`); await next(); }; }; /** * 获取当前限流统计(调试用) */ module.exports.getStats = () => { const stats = {}; for (const [key, entry] of counters) { stats[key] = { count: entry.count, resetAt: new Date(entry.resetTime).toISOString() }; } return stats; }; /** * 清除所有计数器(测试/重置用) */ module.exports.reset = () => { counters.clear(); };