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:
116
app/middleware/auth.js
Normal file
116
app/middleware/auth.js
Normal file
@@ -0,0 +1,116 @@
|
||||
'use strict';
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
/**
|
||||
* JWT 认证中间件
|
||||
*
|
||||
* 功能:
|
||||
* 1. 从请求头 Authorization 中提取 Bearer Token
|
||||
* 2. 验证 JWT 的有效性
|
||||
* 3. 将解码后的用户信息挂载到 ctx.state.currentUser
|
||||
* 4. 支持管理员角色校验(admin 路由专用)
|
||||
* 5. 验证失败返回统一错误响应
|
||||
*
|
||||
* 使用方式:
|
||||
* - 选手认证:app.middleware.auth()
|
||||
* - 管理员认证:app.middleware.auth({ adminOnly: true })
|
||||
*
|
||||
* ctx.state.currentUser 数据结构:
|
||||
* {
|
||||
* uid: 账号 uid,
|
||||
* id_number: 身份证号,
|
||||
* role: 'contestant' | 'admin'
|
||||
* }
|
||||
*/
|
||||
|
||||
module.exports = (options = {}, app) => {
|
||||
return async function authMiddleware(ctx, next) {
|
||||
// 从 ctx.app.config 中获取 JWT 密钥
|
||||
const appConfig = app || ctx.app;
|
||||
const { secret } = appConfig.config.jwt;
|
||||
|
||||
// 获取 Authorization 请求头;同时支持 Query ?token= 回退(用于跨设备直链下载,
|
||||
// 如 iOS Safari 不响应 blob + a.download 时改用浏览器原生导航下载的场景)。
|
||||
const authorization = ctx.get('Authorization') ||
|
||||
(ctx.query && ctx.query.token ? `Bearer ${ctx.query.token}` : '');
|
||||
|
||||
// 检查 Authorization 头是否存在
|
||||
if (!authorization) {
|
||||
ctx.status = 401;
|
||||
ctx.body = {
|
||||
code: 401,
|
||||
message: '未提供认证令牌,请先登录',
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// 提取 Bearer Token(格式:Bearer <token>)
|
||||
const tokenParts = authorization.split(' ');
|
||||
if (tokenParts.length !== 2 || tokenParts[0] !== 'Bearer') {
|
||||
ctx.status = 401;
|
||||
ctx.body = {
|
||||
code: 401,
|
||||
message: '认证令牌格式错误,应为 Bearer <token>',
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const token = tokenParts[1];
|
||||
|
||||
// 验证 JWT Token
|
||||
try {
|
||||
const decoded = jwt.verify(token, secret);
|
||||
|
||||
// 将解码后的用户信息挂载到 ctx.state.currentUser
|
||||
ctx.state.currentUser = {
|
||||
id: decoded.id || decoded.uid || 0,
|
||||
uid: decoded.uid || decoded.id || 0,
|
||||
id_number: decoded.id_number || '',
|
||||
phone: decoded.phone || '',
|
||||
role: decoded.role,
|
||||
};
|
||||
|
||||
// 如果启用了管理员模式,校验当前用户是否为管理员
|
||||
if (options.adminOnly) {
|
||||
if (ctx.state.currentUser.role !== 'admin') {
|
||||
ctx.status = 403;
|
||||
ctx.body = {
|
||||
code: 403,
|
||||
message: '无权限访问,需要管理员权限',
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 继续执行后续中间件和控制器
|
||||
await next();
|
||||
} catch (err) {
|
||||
// 仅当错误确实是 JWT 校验问题(过期/签名无效)时,才返回 401「认证失败」。
|
||||
// 注意:try 内部包含 `await next()`,即后续中间件与控制器也在同一 try 中。
|
||||
// 若下游控制器/服务抛出异常(如文件读取失败、数据库异常等),绝不能被
|
||||
// 这里误吞并伪装成 401,否则前端会误判「登录过期」把用户踢回登录页。
|
||||
// 因此:非 JWT 类错误必须重新抛出,交由 Egg 全局错误处理(返回 500 并写日志)。
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
// Token 已过期
|
||||
ctx.status = 401;
|
||||
ctx.body = {
|
||||
code: 401,
|
||||
message: '认证令牌已过期,请重新登录',
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
// Token 无效(签名错误、格式错误等)
|
||||
ctx.status = 401;
|
||||
ctx.body = {
|
||||
code: 401,
|
||||
message: '无效的认证令牌',
|
||||
};
|
||||
return;
|
||||
}
|
||||
// 其它异常(下游业务逻辑抛出):原样抛出,避免被误报为 401
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
};
|
||||
53
app/middleware/concurrency_guard.js
Normal file
53
app/middleware/concurrency_guard.js
Normal file
@@ -0,0 +1,53 @@
|
||||
'use strict';
|
||||
|
||||
// ==================== 请求级并发闸门(大文件上传/下载) ====================
|
||||
// 背景(2026-08-11 压测结论):
|
||||
// 场景「无带宽限制 + 单 worker + 极端并发」(大量考生同时上传大文件 +
|
||||
// 管理后台同时下载大文件)下,单 worker 即使有文件 IO 护栏(file_io_guard),
|
||||
// 仍会在「被接受的并发重请求数」过高时被海量大文件流压垮(OOM 被系统杀掉,
|
||||
// 无任何 Node 异常日志)。原因是 file_io_guard 只限制「同时进行的文件流」,
|
||||
// 而 90 个并发请求的连接已先被 TCP 接受、请求体已在传输中。
|
||||
//
|
||||
// 本闸门在「读取请求体之前」对重 IO 接口做全局并发上限:
|
||||
// - 命中重路由且 active < MAX:放行(active++),请求结束(finally)后 active--;
|
||||
// - 命中重路由且 active >= MAX:立即回 429(连接级背压),请求体不会被解析/缓冲,
|
||||
// 由前端 uploadWithRetry / 下载侧重试指数退避重试 —— 保证后端存活而非雪崩。
|
||||
// - 非重路由:直接放行,不影响登录/统计等轻接口。
|
||||
//
|
||||
// 与 file_io_guard 互补:本闸门限制「被接受的并发重请求数」(防连接/内存被打满),
|
||||
// file_io_guard 限制「同时进行的文件流数(按流生命周期释放)」(防 fd/事件循环耗尽)。
|
||||
// 双保险,使单 worker 在极端并发下「降级(429)而非崩溃」。
|
||||
|
||||
const HEAVY_PATTERNS = [
|
||||
/^\/api\/upload\/[^/]+\/chunk$/i,
|
||||
/^\/api\/upload\/[^/]+\/merge$/i,
|
||||
/^\/api\/exam\/[^/]+\/word$/i,
|
||||
/^\/api\/admin\/submission-file\//i,
|
||||
/^\/api\/admin\/submissions\/[^/]+\/[^/]+\/word$/i,
|
||||
];
|
||||
|
||||
module.exports = (options, app) => {
|
||||
const MAX = parseInt(process.env.HEAVY_REQ_MAX || (options && options.max) || '20', 10);
|
||||
|
||||
let active = 0;
|
||||
|
||||
return async function concurrencyGuard(ctx, next) {
|
||||
if (!HEAVY_PATTERNS.some((re) => re.test(ctx.path))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (active >= MAX) {
|
||||
// 🔴 连接级背压:直接拒绝,避免请求体被解析/缓冲占用内存
|
||||
ctx.status = 429;
|
||||
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
active++;
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
};
|
||||
};
|
||||
73
app/middleware/loginRateLimit.js
Normal file
73
app/middleware/loginRateLimit.js
Normal file
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 登录限流中间件 - loginRateLimit
|
||||
*
|
||||
* 面向 /api/auth/login 与 /api/auth/admin-login,防止暴力破解。
|
||||
*
|
||||
* 策略:
|
||||
* - 以「客户端 IP + 登录标识(phone/username)」为维度,在滑动窗口内统计「失败」次数。
|
||||
* - 达到阈值后,在窗口结束前拒绝该维度的后续登录尝试(返回业务错误码 4290)。
|
||||
* - 登录成功(响应 code === 0)立即清空该维度的失败计数。
|
||||
*
|
||||
* 说明:采用进程内内存计数(Map),零外部依赖、不影响本地/无 Redis 环境。
|
||||
* 多进程(egg-cluster)下各 worker 各自计数,属于可接受的基线防护;
|
||||
* 如需全局精确限流,可后续替换为基于 Redis 的实现。
|
||||
*
|
||||
* @param {object} options - 配置项
|
||||
* @param {number} [options.windowMs=900000] - 统计窗口(毫秒),默认 15 分钟
|
||||
* @param {number} [options.maxAttempts=10] - 窗口内允许的最大失败次数
|
||||
* @return {Function} Egg 中间件
|
||||
*/
|
||||
module.exports = (options = {}) => {
|
||||
const windowMs = options.windowMs || 15 * 60 * 1000;
|
||||
const maxAttempts = options.maxAttempts || 10;
|
||||
|
||||
// key -> { count, first }
|
||||
const store = new Map();
|
||||
|
||||
const cleanup = (now) => {
|
||||
for (const [ k, v ] of store) {
|
||||
if (now - v.first >= windowMs) store.delete(k);
|
||||
}
|
||||
};
|
||||
|
||||
return async function loginRateLimit(ctx, next) {
|
||||
const body = ctx.request.body || {};
|
||||
const identifier = body.phone || body.username || 'unknown';
|
||||
const key = `${ctx.ip}|${identifier}`;
|
||||
const now = Date.now();
|
||||
|
||||
let rec = store.get(key);
|
||||
// 窗口已过期则视为无记录
|
||||
if (rec && now - rec.first >= windowMs) {
|
||||
store.delete(key);
|
||||
rec = undefined;
|
||||
}
|
||||
|
||||
// 已超过阈值:直接拒绝
|
||||
if (rec && rec.count >= maxAttempts) {
|
||||
const remainMin = Math.ceil((windowMs - (now - rec.first)) / 60000);
|
||||
ctx.logger.warn(`[登录限流] 拒绝来自 ${key} 的登录尝试(已失败 ${rec.count} 次)`);
|
||||
ctx.body = ctx.helper.error(4290, `登录尝试过于频繁,请约 ${remainMin} 分钟后再试`);
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
|
||||
// 依据响应判定成败:统一响应体 { code: 0 } 为成功
|
||||
const succeeded = ctx.body && ctx.body.code === 0;
|
||||
if (succeeded) {
|
||||
store.delete(key);
|
||||
} else {
|
||||
if (rec) {
|
||||
rec.count += 1;
|
||||
} else {
|
||||
store.set(key, { count: 1, first: now });
|
||||
}
|
||||
}
|
||||
|
||||
// 惰性清理,避免 Map 无限增长
|
||||
if (store.size > 5000) cleanup(now);
|
||||
};
|
||||
};
|
||||
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