140 lines
4.1 KiB
JavaScript
140 lines
4.1 KiB
JavaScript
|
|
'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();
|
|||
|
|
};
|