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

116
app/middleware/auth.js Normal file
View 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;
}
};
};

View 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--;
}
};
};

View 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
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();
};