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:
WorkBuddy
2026-08-17 12:25:42 +08:00
commit a303cf2d53
109 changed files with 25904 additions and 0 deletions

139
app/middleware/rateLimit.js Normal file
View 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();
};