Files
competition-website/app/extend/helper.js
qubiaobiao 23604f2718 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

290 lines
9.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const fs = require('fs');
// 🔴 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';
}
},
};