'use strict'; const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const fs = require('fs'); const bcrypt = require('bcryptjs'); // 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} bcrypt 哈希字符串 */ async hashPassword(plain) { return bcrypt.hash(plain, BCRYPT_ROUNDS); }, /** * 校验明文密码与存储哈希是否匹配。 * 兼容 bcrypt 哈希与存量 SHA256(无盐)哈希,实现平滑迁移。 * * @param {string} plain - 明文密码 * @param {string} hash - 存储的哈希(bcrypt 或旧版 SHA256) * @returns {Promise} 是否匹配 */ 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} 文件的 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'; } }, };