Files

290 lines
9.9 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const fs = require('fs');
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
// 🔴 R8(2026-09-21):原生 bcrypt(N-API),**不是** bcryptjs。
// 原实现 `require('bcryptjs')` 是纯 JS、**同步阻塞事件循环** —— 实测 16 并发 compare
// 耗时 1129ms,且期间**心跳达成率 0%**(主线程被冻结 1.1s,无法 accept 新连接),
// 这正是登录链路需要"并发闸门"兜底的根因。原生实现走 libuv 线程池:
// 同批 compare 265ms(**4.20×**)、心跳持续(环境实测见 probes/_bench_eventloop.cjs)。
//
// ✅ 兼容性(已验证,drop-in,无需数据迁移):
// · API 同形:hash / compare / hashSync / compareSync 均已实测存在;
// · 哈希格式同形:产出仍为 `$2b$10$`,与存量 26/26 条完全一致,可互相比对;
// · 是 **N-API** 构建(prebuilds 文件名无 ABI 后缀)⇒ node 18(ABI 108) 与
// 22(ABI 127) 同一份二进制均可 load;包内自带 win32-x64 与
// linux-x64(glibc + musl)预编译 ⇒ 本地与 Linux 生产**均无需编译**。
//
// ⚠️ 生产部署影响:原生模块**不会**被 rsync 源码带走,服务器须重新 `npm install`
// (见交付报告「上线前置」)。⛔ 不要把 `bcryptjs` 残留为依赖 —— 留着只会
// 让后人误以为仍在用纯 JS 实现。
const bcrypt = require('bcrypt');
// bcrypt 计算强度(10 轮,登录场景性能与安全的平衡点)
const BCRYPT_ROUNDS = 10;
/**
* 应用扩展工具函数 - Helper
*
* 提供加密、JWT、文件哈希、统一响应等工具函数。
* 在控制器中通过 ctx.helper.xxx() 调用。
*/
module.exports = {
/**
* SHA256 哈希计算(可选盐值)
*
* @param {string} password - 明文密码
* @param {string} [salt=''] - 盐值(可选,不传则不加盐)
* @returns {string} SHA256 哈希后的密码字符串
*
* 示例:
* const hashed = ctx.helper.sha256('123456', 'abc123salt');
* const hashed2 = ctx.helper.sha256('123456'); // 无盐值
*/
sha256(password, salt) {
const hash = crypto.createHash('sha256');
hash.update(password + (salt || ''));
return hash.digest('hex');
},
/**
* 生成随机盐值
*
* @returns {string} 32 位随机十六进制字符串
*
* 示例:
* const salt = ctx.helper.generateSalt();
* // => 'a1b2c3d4e5f6...' (32位)
*/
generateSalt() {
return crypto.randomBytes(16).toString('hex');
},
/**
* 判断给定哈希是否为 bcrypt 格式($2a$ / $2b$ / $2y$ 开头)
*
* @param {string} hash - 待判断的哈希字符串
* @returns {boolean} 是否为 bcrypt 哈希
*/
isBcryptHash(hash) {
return typeof hash === 'string' && /^\$2[aby]\$/.test(hash);
},
/**
* 使用 bcrypt 生成加盐密码哈希(新密码统一走此方法)
*
* @param {string} plain - 明文密码
* @returns {Promise<string>} bcrypt 哈希字符串
*/
async hashPassword(plain) {
return bcrypt.hash(plain, BCRYPT_ROUNDS);
},
/**
* 校验明文密码与存储哈希是否匹配。
* 兼容 bcrypt 哈希与存量 SHA256(无盐)哈希,实现平滑迁移。
*
* @param {string} plain - 明文密码
* @param {string} hash - 存储的哈希(bcrypt 或旧版 SHA256)
* @returns {Promise<boolean>} 是否匹配
*/
async comparePassword(plain, hash) {
if (!hash) return false;
if (this.isBcryptHash(hash)) {
return bcrypt.compare(plain, hash);
}
// 兼容存量 SHA256(无盐)密码
return this.sha256(plain) === hash;
},
/**
* 生成 JWT Token
*
* @param {Object} payload - 要编码的载荷数据
* @param {string} secret - JWT 签名密钥
* @param {string} [expiresIn] - 有效期,默认使用配置文件中的 '24h'
* @returns {string} 生成的 JWT Token 字符串
*
* 示例:
* const token = ctx.helper.generateToken({ uid: 1, role: 'contestant' }, secret);
*/
generateToken(payload, secret, expiresIn) {
const finalExpiresIn = expiresIn || this.config.jwt.expiresIn;
return jwt.sign(payload, secret, {
expiresIn: finalExpiresIn,
});
},
/**
* 验证 JWT Token
*
* @param {string} token - JWT Token 字符串
* @param {string} secret - JWT 签名密钥
* @returns {Object} 解码后的载荷数据,验证失败抛出异常
*
* 示例:
* const decoded = ctx.helper.verifyToken(token, secret);
*/
verifyToken(token, secret, options = {}) {
return jwt.verify(token, secret, options);
},
/**
* 统一成功响应
*
* @param {*} data - 响应数据,默认 null
* @param {string} message - 响应消息,默认 'success'
* @returns {Object} 统一格式的成功响应 { code: 0, message, data }
*
* 示例:
* ctx.body = ctx.helper.success({ id: 1 }, '操作成功');
* // => { code: 0, message: '操作成功', data: { id: 1 } }
*/
success(data = null, message = 'success') {
return {
code: 0,
message,
data,
};
},
/**
* 统一失败/错误响应
*
* @param {number} code - 错误码
* @param {string} message - 错误消息
* @returns {Object} 统一格式的错误响应 { code, message }
*
* 示例:
* ctx.body = ctx.helper.error(1001, '用户不存在');
* // => { code: 1001, message: '用户不存在' }
*/
error(code, message) {
return {
code,
message,
};
},
/**
* 生成随机数字验证码
*
* @param {number} length - 验证码长度,默认 6
* @returns {string} 指定长度的数字验证码字符串
*
* 示例:
* ctx.helper.randomCode(6);
* // => '372819'
*/
randomCode(length = 6) {
let code = '';
for (let i = 0; i < length; i++) {
code += Math.floor(Math.random() * 10);
}
return code;
},
/**
* 生成文件 MD5 哈希
*
* @param {string} filePath - 文件完整路径
* @returns {Promise<string>} 文件的 MD5 哈希字符串
*
* 示例:
* const md5 = await ctx.helper.generateFileMd5('/path/to/file.pdf');
* // => 'd41d8cd98f00b204e9800998ecf8427e'
*/
async generateFileMd5(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => {
hash.update(chunk);
});
stream.on('end', () => {
resolve(hash.digest('hex'));
});
stream.on('error', (err) => {
reject(err);
});
});
},
/**
* 构建文件访问 URL
*
* 将 app/public 下的相对路径转换为完整的 HTTP 访问 URL。
* 前端通过此 URL 可直接访问上传的文件。
*
* @param {string} relativePath - 相对路径(如 'carousel/banner.jpg' 或 'templates/zhongzhi/template.docx')
* @returns {string} 完整的外部访问 URL
*
* 示例:
* ctx.helper.fileUrl('carousel/banner.jpg');
* // => 'http://127.0.0.1:7001/public/carousel/banner.jpg'
*
* ctx.helper.fileUrl('templates/zhongzhi/template.docx');
* // => 'http://127.0.0.1:7001/public/templates/zhongzhi/template.docx'
*/
fileUrl(relativePath) {
const { app } = this.ctx;
// 支持通过环境变量配置域名,默认使用 localhost:端口
const baseUrl = app.config.staticBaseUrl || `http://127.0.0.1:${app.config.cluster.listen.port}`;
// 确保路径以 /public/ 开头
const normalizedPath = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
return `${baseUrl}/public/${normalizedPath}`;
},
/**
* 🔴 best-effort 清理:删除临时文件/目录,**任何失败都不抛错**。
*
* 【为什么必须存在】临时目录/半截文件的清理是**副作用**,不是业务结果。
* 若把它写成裸 fs.rmdirSync / fs.unlinkSync 放在成功路径里,一旦删除失败
* (杀毒软件锁文件、Windows 句柄未释放、只读挂载、沙箱安全钩子拦截……),
* 整个请求就会抛异常走到 catch → 返回 500,而此时:
* · 目标文件其实已经落盘 → 产生**孤儿文件**
* · DB 记录尚未写入 → **文件与数据不一致**
* · 用户看到"失败"后重试 → 再生成一个孤儿文件,越滚越多
* 实证:controller/upload.js:mergeChunks 与 service/admin.js:createBatchZip
* 都曾因临时目录删除失败而整体 500(common-error.log 有完整堆栈)。
*
* 【使用约定】
* · 清理副作用 → 一律用本函数,永不裸调 fs 删除 API。
* · "删除即业务动作"(如管理员删除备赛资料)→ 不要用本函数,
* 那种场景删除失败必须让用户知道,应当照常抛错/返回错误码。
*
* @param {string} targetPath - 待删除的文件或目录绝对路径
* @param {object} [opts] - { label:'用于日志的场景名' }
* @returns {'removed'|'missing'|'skipped'} 删除结果(不抛错)
*/
safeCleanup(targetPath, opts = {}) {
const label = opts.label || 'cleanup';
const logger = (this.ctx && this.ctx.logger) || console;
if (!targetPath) return 'missing';
try {
if (!fs.existsSync(targetPath)) return 'missing';
const st = fs.statSync(targetPath);
if (st.isDirectory()) {
// fs.rm 优先(Node 14.14+ 官方推荐,force 语义更稳);老版本回退 rmdirSync
if (typeof fs.rmSync === 'function') {
fs.rmSync(targetPath, { recursive: true, force: true });
} else {
fs.rmdirSync(targetPath, { recursive: true });
}
} else {
fs.unlinkSync(targetPath);
}
return 'removed';
} catch (e) {
// 只告警不抛错:清理失败绝不能污染主业务结果
logger.warn(`[safeCleanup:${label}] 清理失败(已忽略,不影响主流程): ${targetPath} -> ${e && e.message}`);
return 'skipped';
}
},
};