Files
competition-website/app/middleware/rateLimit.js
WorkBuddy a303cf2d53 chore: 后端首次基线提交
运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。
已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
2026-08-17 12:25:42 +08:00

140 lines
4.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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();
};