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*、构建/缓存产物。
This commit is contained in:
139
app/middleware/rateLimit.js
Normal file
139
app/middleware/rateLimit.js
Normal file
@@ -0,0 +1,139 @@
|
||||
'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();
|
||||
};
|
||||
Reference in New Issue
Block a user