'use strict'; const Service = require('egg').Service; const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); /** * 上传服务 - UploadService * * 封装作品文件上传相关业务逻辑,包括截止时间校验、文件保存、 * 分片上传与合并等功能。适用于本科组和教师组的作品提交。 */ class UploadService extends Service { /** * 获取用户的聚合报名信息 * @param {number} accountUid - 账号 UID * @returns {Promise} 聚合报名记录 */ async getUserAggregation(accountUid) { const { ctx } = this; return await ctx.model.Aggregation.findOne({ where: { account_uid: accountUid }, include: [ { model: ctx.model.Team, as: 'team', attributes: ['team_uid', 'group_name', 'team_id', 'team_number'], }, ], }); } /** * 验证提交截止时间 * @param {string} group - 组别:benke / jiaoshi * @returns {Promise} { valid, message, deadline } */ async validateDeadline(group) { const { ctx } = this; const timeConfig = await ctx.model.CompetitionTime.findOne({ where: { group }, }); // 权威截止时间 = end_time(管理后台「提交截止时间」输入框绑定的正是该列)。 // 历史遗留的 deadline 列仅作后备,避免出现「状态标签(按 end_time)显示进行中、 // 上传却按旧 deadline 判定已截止」的两条时间线不一致问题。 const effective = (timeConfig && (timeConfig.end_time || timeConfig.deadline)) || null; if (!effective) { return { valid: false, message: '提交截止时间尚未设置', deadline: null, }; } const now = new Date(); const deadline = new Date(effective); if (now > deadline) { return { valid: false, message: '提交已截止', deadline: deadline.toISOString(), }; } return { valid: true, message: '可以提交', deadline: deadline.toISOString(), }; } /** * 保存作品文件 * @param {ReadableStream} stream - 文件流 * @param {string} filename - 原始文件名 * @param {string} group - 组别 * @returns {Promise} { filePath, fileName, fileSize, md5 } */ async saveWorkFile(stream, filename, group) { const { app } = this; // 确保上传目录存在(无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT) const uploadDir = path.join(app.baseDir, 'app/public/works', group); try { fs.mkdirSync(uploadDir, { recursive: true }); } catch (mkErr) { this.app.logger.error('[saveWorkFile] mkdir failed', { uploadDir, message: mkErr.message }); throw mkErr; } // 构建安全文件名 const ext = path.extname(filename); const timestamp = Date.now(); const safeFileName = `work_${timestamp}_${Math.random().toString(36).substring(2, 8)}${ext}`; const filePath = path.join(uploadDir, safeFileName); // 将文件流写入磁盘(用 pipe 自动处理背压,避免大文件内存暴涨 / 写入被截断) const writeStream = fs.createWriteStream(filePath); let fileSize = 0; await new Promise((resolve, reject) => { stream.pipe(writeStream); stream.on('data', chunk => { fileSize += chunk.length; }); writeStream.on('finish', resolve); stream.on('error', reject); writeStream.on('error', reject); }); // 🔴 落盘完整性校验(F17-B 防「假成功」): // 文件必须真实存在且字节数与流读数一致,否则视为上传失败, // 绝不允许把「写了一半 / 没写出来的文件」记成成功——那会导致管理后台下载时 404。 const stat = fs.statSync(filePath); if (stat.size !== fileSize) { // 🔴 清理半截文件用 best-effort(同源缺陷族第三处): // 裸 unlinkSync 一旦失败会抛出「删除失败」,把下面那条真正有诊断价值的 // 「文件写入不完整:期望 X 实际 Y」**顶掉**,排障时被引向错误方向。 // 清理成不成功都不影响结论——写入不完整这件事必须原样抛出。 this.ctx.helper.safeCleanup(filePath, { label: 'partialFile' }); throw new Error(`文件写入不完整:期望 ${fileSize} 字节,实际 ${stat.size} 字节`); } // 计算文件 MD5 const md5 = await this._calculateMd5(filePath); // 构建相对路径(相对于 app/public/) const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/'); return { filePath: relativePath, fileName: filename, fileSize, md5, }; } /** * 合并文件分片 * @param {Array} chunks - 分片文件路径数组(按索引排序) * @param {string} targetPath - 目标文件路径 * @returns {Promise} { filePath, fileName, fileSize, md5 } */ async mergeFileChunks(chunks, targetPath) { // 确保目标目录存在(无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT) const targetDir = path.dirname(targetPath); try { fs.mkdirSync(targetDir, { recursive: true }); } catch (mkErr) { this.app.logger.error('[mergeFileChunks] mkdir failed', { targetDir, message: mkErr.message }); throw mkErr; } // 按顺序合并所有分片。 // 🔴 并发要点(2026-08-10 修):这里过去用 fs.readFileSync 把整个分片同步读进内存, // 再 writeStream.write(buf) 且不等 drain。后果有二: // ① 同步读盘会「冻结 event loop」——一个 50MB 文件合并期间,同一 worker 上 // 其它考生的上传/答题请求全部排队,几十人同时交卷时表现为集体卡死; // ② 不理会背压,N 个分片 Buffer 全堆在写队列里,单次合并瞬时吃掉整份文件的内存。 // 现改为逐分片流式 pipe({ end: false } 续写),全程异步 + 自动背压, // 内存占用恒定在一个 highWaterMark(64KB),event loop 不再被阻塞。 // // 🔴 监听器修复(2026-08-11):原实现在「循环内」对每个分片都执行 // `writeStream.on('error', reject)`,单一 writeStream 会被挂上 ≈分片数 个 // error 监听(10 分片 → 11 个),极端并发下触发 MaxListenersExceededWarning // 并拖垮单 worker。现改为 writeStream.error 只挂【一次】,并用 settled 防重入。 let totalSize = 0; await new Promise((resolve, reject) => { const writeStream = fs.createWriteStream(targetPath); let settled = false; const fail = (err) => { if (settled) return; settled = true; writeStream.destroy(); reject(err); }; // 仅挂一次:writeStream 自身的错误 writeStream.on('error', fail); (async () => { try { for (const chunkPath of chunks) { await new Promise((res, rej) => { const readStream = fs.createReadStream(chunkPath); // 每个 readStream 是独立 emitter,挂一个 error 监听是合理的 readStream.on('error', rej); readStream.on('data', buf => { totalSize += buf.length; }); readStream.on('end', res); readStream.pipe(writeStream, { end: false }); }); // 删除已合并的分片文件(异步,避免同步 unlink 累积阻塞) await fs.promises.unlink(chunkPath).catch(() => {}); } writeStream.end(); writeStream.on('finish', () => { if (settled) return; settled = true; resolve(); }); } catch (e) { fail(e); } })(); }).catch((mergeErr) => { // 清理半截目标文件,绝不把不完整文件记为成功(F17-B 防假成功) return fs.promises.unlink(targetPath).catch(() => {}).then(() => { throw mergeErr; }); }); // 落盘完整性校验:合并后字节数必须与读入总量一致 const mergedStat = await fs.promises.stat(targetPath); if (mergedStat.size !== totalSize) { await fs.promises.unlink(targetPath).catch(() => {}); throw new Error(`分片合并不完整:期望 ${totalSize} 字节,实际 ${mergedStat.size} 字节`); } // 计算合并后文件的 MD5 const md5 = await this._calculateMd5(targetPath); const fileName = path.basename(targetPath); return { filePath: targetPath, fileName, fileSize: totalSize, md5, }; } /** * 计算文件 MD5 值 * @param {string} filePath - 文件路径 * @returns {Promise} MD5 哈希值 */ async _calculateMd5(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', reject); }); } /** * 保存作品提交记录到数据库 * @param {number} aggregationUid - 聚合报名 ID * @param {string} group - 组别 * @param {Object} fileInfo - 文件信息 { filePath, fileName, fileSize, md5 } * @param {string} workName - 作品名称(可选) * @returns {Promise} 保存的记录 */ async saveSubmissionRecord(aggregationUid, group, fileInfo, workName = '', fileType = 'word') { const { ctx } = this; const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission; // 按 (aggregation_uid, file_type) 定位记录:Word / PPT 各自独立存储, // 避免后上传的文件覆盖先上传的文件(修复 PPT 顶掉 Word 的问题)。 const existing = await SubmissionModel.findOne({ where: { aggregation_uid: aggregationUid, file_type: fileType }, }); const payload = { work_name: workName || fileInfo.fileName, file_path: fileInfo.filePath, file_name: fileInfo.fileName, file_size: fileInfo.fileSize, file_md5: fileInfo.md5, upload_time: new Date(), file_type: fileType, }; if (existing) { await existing.update({ ...payload, score: null, // 重新上传后清除评分 }); return existing; } return await SubmissionModel.create({ aggregation_uid: aggregationUid, ...payload, }); } /** * 查询用户的提交状态 * @param {number} aggregationUid - 聚合报名 ID * @param {string} group - 组别 * @returns {Promise} 是否已提交 */ async hasSubmitted(aggregationUid, group) { const { ctx } = this; const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission; const count = await SubmissionModel.count({ where: { aggregation_uid: aggregationUid }, }); return count > 0; } /** * 获取用户的提交历史列表 * @param {number} aggregationUid - 聚合报名 ID * @param {string} group - 组别 * @returns {Promise} 提交记录列表 */ async getSubmissions(aggregationUid, group) { const { ctx } = this; const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission; return await SubmissionModel.findAll({ where: { aggregation_uid: aggregationUid }, order: [['upload_time', 'DESC']], include: [ { model: ctx.model.Aggregation, as: 'aggregation', attributes: ['aggregation_uid', 'team_uid', 'account_uid', 'identity'], }, ], }); } /** * 删除用户的提交记录及其物理文件 * @param {number} aggregationUid - 聚合报名 ID * @param {string} group - 组别 * @param {number} id - 提交记录 ID * @returns {Promise} 删除成功返回 true,未找到(或无权限)返回 false */ async deleteSubmission(aggregationUid, group, id) { const { ctx, app } = this; const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission; // 仅允许删除本人名下的记录(聚合键强约束,防越权) const record = await SubmissionModel.findOne({ where: { id, aggregation_uid: aggregationUid }, }); if (!record) return false; // 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx) if (record.file_path) { const absPath = path.join(app.baseDir, 'app/public', record.file_path); try { if (fs.existsSync(absPath)) fs.unlinkSync(absPath); } catch (e) { ctx.logger.error('[deleteSubmission] 删除物理文件失败:', e.message); } } await record.destroy(); return true; } } module.exports = UploadService;