'use strict'; const Controller = require('egg').Controller; const path = require('path'); const fs = require('fs'); const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard'); /** * 上传控制器 - UploadController * * 负责作品文件上传相关功能,包括普通上传、分片上传、分片查询、 * 合并分片、提交状态查询等。适用于本科组和教师组。 * * 主要接口: * - POST /api/upload/:group : 上传作品文件 * - POST /api/upload/:group/chunk : 上传分片 * - POST /api/upload/:group/merge : 合并分片 * - GET /api/upload/:group/chunk-status : 查询分片状态 * - GET /api/upload/:group/status : 查询提交状态 * - GET /api/upload/:group/submissions : 查询提交历史 */ class UploadController extends Controller { /** * 校验组别参数 * @param {string} group - 组别 * @returns {boolean} 是否有效 */ _validateGroup(group) { const validGroups = ['benke', 'jiaoshi']; if (!validGroups.includes(group)) { this.ctx.body = this.ctx.helper.error(400, '无效的组别,仅支持 benke / jiaoshi'); return false; } return true; } /** * 获取用户的聚合报名信息 * @returns {Promise} */ async _getUserAggregation() { const user = this.ctx.state.currentUser; if (!user || !user.uid) return null; return await this.service.upload.getUserAggregation(user.uid); } /** * 上传作品文件 * POST /api/upload/:group * * 支持 .pdf / .doc / .docx / .ppt / .pptx / .zip 格式,最大 50MB */ async upload() { const { ctx, service } = this; // 🔴 配额获取标志(见方法末 finally 的说明:为何不照搬 uploadChunk 的无条件 release) let ioAcquired = false; // 🔴 C9 根因①的孪生缺陷(2026-09-29 · R45):文件已落盘、但 DB 记录没写成 ⇒ 同样是孤儿。 // 两个标志分别表示「已落盘」「已入库」,只有「落盘但没入库」才允许回滚删除 —— // 若已经入库还去删文件,就会删掉**正在被引用**的文件(下载 404),那是数据丢失。 let savedFileInfo = null; let recordPersisted = false; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; // 🔴 文件 IO 并发护栏(2026-09-29 · R43 补齐 —— 本端点此前**零护栏**) // // 背景:本端点是**在用的**作品上传路径(前端 Competition.vue:477 逐文件串行调用), // 而同文件的 uploadChunk(:156) / mergeChunks(:275) / exam.uploadWord(:503) 都调了本护栏, // 唯独真正承载考生上传的这一条**既未调 file_io_guard、也不在 concurrency_guard // 的 HEAVY_PATTERNS 内**(R42 发现,已同步补入中间件)。两处都补 = 闸门 + 护栏双保险。 // // 位置:与 uploadChunk 同构 —— 在**读取请求体之前**(ctx.getFileStream() 在下方)取配额, // 提供连接级背压:极端并发下超限即回 429,请求体不会被解析/缓冲,避免 fd/事件循环雪崩。 // // ⚠️ 生产缓解事实(诚实标注,避免高估本改动):服务器入方向带宽仅 ≈1.25 MB/s, // 收包速率被物理封顶、并发上传大多耗在「等字节」⇒ **当前实际风险有限**; // 但一旦按 C3 提升入带宽,收包不再被封顶,护栏即从「保险」变为「必需」 // (故 C7 与 C3 绑定:先补护栏再升带宽)。 try { await acquireFileIo(); ioAcquired = true; } catch (e) { ctx.status = 429; ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试'); return; } // 验证截止时间 const deadlineCheck = await service.upload.validateDeadline(group); if (!deadlineCheck.valid) { ctx.body = ctx.helper.error(6001, deadlineCheck.message); return; } // 获取用户聚合信息 const aggregation = await this._getUserAggregation(); if (!aggregation) { ctx.body = ctx.helper.error(403, '未找到您的报名信息'); return; } // 验证组别匹配 if (aggregation.team && aggregation.team.group_name !== group) { ctx.body = ctx.helper.error(403, '您不属于该组别'); return; } // 🔴 R190 组内上传互斥:本科/教师组「小组中只要有一人上传,其他人就不能上传」, // 替换权只归组内最早提交者(归属人)。⚠️ 必须放在 ctx.getFileStream() **之前** —— // 被锁成员根本不该把几十 MB 请求体传上来再被拒(白耗入带宽 + 通道配额)。 const gate = await service.upload.assertTeamUploadAllowed(aggregation, group); if (!gate.ok) { // 业务错误 = HTTP 200 + code≠0(helper.error 不改 HTTP 状态), // 附加 data 供前端在按钮/提示里展示归属人,message 已含完整可读文案。 const body = ctx.helper.error(6006, gate.message); body.data = { teamLocked: true, teamOwnerName: gate.state.owner && gate.state.owner.name ? gate.state.owner.name : null, teamNumber: gate.state.teamNumber, folderName: gate.state.folderName, }; ctx.body = body; return; } const teamFolder = gate.state.folderName; // 获取上传的文件流 const stream = await ctx.getFileStream(); if (!stream) { ctx.body = ctx.helper.error(400, '未接收到文件'); return; } // 验证文件扩展名 const allowedExts = ['.pdf', '.doc', '.docx', '.ppt', '.pptx', '.zip']; const ext = path.extname(stream.filename).toLowerCase(); if (!allowedExts.includes(ext)) { ctx.body = ctx.helper.error(6002, '不支持的文件格式,仅支持 pdf/doc/docx/ppt/pptx/zip'); return; } // 验证文件大小:zip 最大 200MB,其余格式最大 100MB(从请求头判断) const contentLength = parseInt(ctx.get('content-length')) || 0; const isZip = ext === '.zip'; const maxSize = isZip ? 200 * 1024 * 1024 : 100 * 1024 * 1024; const limitLabel = isZip ? '200MB' : '100MB'; if (contentLength > maxSize) { ctx.body = ctx.helper.error(6003, `文件大小超过限制(最大 ${limitLabel})`); return; } // 获取作品名称与文件类型(流式模式字段在 stream.fields 中)。 // ⚠️ 必须先取再落盘:落盘要用 file_type 判定「同名文件是否同一次提交的新版本」。 const workName = (stream.fields && stream.fields.work_name) || stream.filename; const fileType = (stream.fields && stream.fields.file_type) || 'word'; // 保存文件:落点 = works//<小组编号>/<原始文件名> const fileInfo = await service.upload.saveWorkFile( stream, stream.filename, group, teamFolder, { fileType, aggregationUid: aggregation.aggregation_uid } ); savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用 // 保存提交记录 const record = await service.upload.saveSubmissionRecord( aggregation.aggregation_uid, group, fileInfo, workName, fileType ); // 🔴 入库成功即置位:此后任何异常都**不得**再删这个文件(它已被 DB 引用) recordPersisted = true; ctx.body = ctx.helper.success({ id: record.id, workName: record.work_name, filePath: fileInfo.filePath, fileUrl: ctx.helper.fileUrl(fileInfo.filePath), fileName: fileInfo.fileName, fileSize: fileInfo.fileSize, md5: fileInfo.md5, uploadTime: record.upload_time || new Date().toISOString(), }, '文件上传成功'); } catch (err) { // 🔴 孤儿回滚(C9 · R45):文件已落盘但记录未入库 ⇒ 立即清掉这个新文件。 // 条件严格限定为 `savedFileInfo && !recordPersisted`: // · 未落盘(校验/护栏阶段失败)→ savedFileInfo 为 null,无事可做; // · 已入库(recordPersisted=true)→ 文件正被 DB 引用,**绝不能删**。 // 仍然 best-effort:清理失败只告警,不能把真正的错误信息顶掉(同 helper.safeCleanup 的约定)。 if (savedFileInfo && savedFileInfo.filePath && !recordPersisted) { service.upload.removePublicFile(savedFileInfo.filePath, 'upload-rollback'); } ctx.logger.error('文件上传失败:', err); const msg = (err && err.message) ? err.message : '未知错误'; ctx.body = ctx.helper.error(500, msg); } finally { // 🔴 释放配额。为何用 `if (ioAcquired)` 而非像 uploadChunk 那样无条件 release: // 本方法的外层 try 包住了「校验 + 取配额 + 落盘」全过程,而 429 分支是 // **acquire 失败**(配额没拿到)后 return 的 —— 若无条件释放,会把**别人的**配额 // 放掉,配额计数下溢 ⇒ 护栏静默失效(真实并发上限被虚高),是最难查的一类退化。 // ⚠️ 反之也不能省 last 这个 finally:中途任何 return 都不允许泄漏配额, // 否则配额只减不增,若干轮后上传通道**永久 429**。 if (ioAcquired) releaseFileIo(); } } /** * 上传分片 * POST /api/upload/:group/chunk * * Body: multipart/form-data 包含 chunk(文件分片)、chunkIndex、totalChunks、fileId、fileName */ // @no-temp-cleanup: 分片接收端,临时目录必须**保留**待 mergeChunks 合并; // 若在此清理则分片无法续传。清理责任在 mergeChunks(upload.js:342 safeCleanup)。 async uploadChunk() { const { ctx, app } = this; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; // 🔴 文件 IO 并发护栏:先于读取请求体获取配额,提供连接级背压; // 极端并发(无带宽限制 + 单 worker)下超出上限则返回 429, // 由前端 uploadWithRetry 指数退避重试,避免 fd/事件循环被压垮雪崩。 try { await acquireFileIo(); } catch (e) { ctx.status = 429; ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试'); return; } try { // 获取上传的文件流(流式模式下,非文件字段需在消费流后从 ctx.request.body 读取) const stream = await ctx.getFileStream(); if (!stream) { ctx.body = ctx.helper.error(400, '未接收到分片数据'); return; } // 流式模式下,非文件字段在 stream.fields 中(非 ctx.request.body) const { chunkIndex, totalChunks, fileId, fileName } = stream.fields || {}; if (chunkIndex === undefined || !totalChunks || !fileId || !fileName) { ctx.body = ctx.helper.error(400, '缺少必要参数:chunkIndex, totalChunks, fileId, fileName'); return; } // 构建临时分片目录 const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } // 保存分片文件 const chunkFilePath = path.join(tempDir, `chunk_${String(chunkIndex).padStart(6, '0')}`); const writeStream = fs.createWriteStream(chunkFilePath); await new Promise((resolve, reject) => { stream.pipe(writeStream); writeStream.on('finish', resolve); writeStream.on('error', reject); stream.on('error', reject); }); ctx.body = ctx.helper.success({ received: parseInt(chunkIndex), totalChunks: parseInt(totalChunks), fileId, }, `分片 ${chunkIndex} 上传成功`); } finally { releaseFileIo(); } } catch (err) { ctx.logger.error('分片上传失败:', err); ctx.body = ctx.helper.error(500, '分片上传失败'); } } /** * 合并分片 * POST /api/upload/:group/merge * * Body: { fileId, fileName, totalChunks, work_name } */ async mergeChunks() { const { ctx, service, app } = this; // 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。 let mergedRelPath = null; // 合并临时文件(先写临时名再原子 rename)。失败时需清掉,否则留下 `.merge_*` 残渣。 let mergedTmpPath = null; let recordPersisted = false; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; // 验证截止时间 const deadlineCheck = await service.upload.validateDeadline(group); if (!deadlineCheck.valid) { ctx.body = ctx.helper.error(6001, deadlineCheck.message); return; } // 获取用户聚合信息 const aggregation = await this._getUserAggregation(); if (!aggregation) { ctx.body = ctx.helper.error(403, '未找到您的报名信息'); return; } const { fileId, fileName, totalChunks, work_name, file_type } = ctx.request.body; if (!fileId || !fileName) { ctx.body = ctx.helper.error(400, '缺少必要参数:fileId, fileName'); return; } // 🔴 R190 组内上传互斥:与 upload() 同一个守卫(两条落盘路径都必须拦, // 否则被锁成员改走分片合并即可绕过组内锁)。 const gate = await service.upload.assertTeamUploadAllowed(aggregation, group); if (!gate.ok) { const body = ctx.helper.error(6006, gate.message); body.data = { teamLocked: true, teamOwnerName: gate.state.owner && gate.state.owner.name ? gate.state.owner.name : null, teamNumber: gate.state.teamNumber, folderName: gate.state.folderName, }; ctx.body = body; return; } const teamFolder = gate.state.folderName; // 检查分片目录 const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId); if (!fs.existsSync(tempDir)) { ctx.body = ctx.helper.error(6004, '未找到分片文件'); return; } // 收集所有分片文件并按文件名排序 const chunkFiles = fs.readdirSync(tempDir) .filter(f => f.startsWith('chunk_')) .sort() .map(f => path.join(tempDir, f)); const expectedCount = parseInt(totalChunks) || 0; if (expectedCount > 0 && chunkFiles.length < expectedCount) { ctx.body = ctx.helper.error(6005, `分片未完成,已收到 ${chunkFiles.length}/${expectedCount}`); return; } // 构建落盘目标:小组目录 + 原始文件名(唯一命名实现 = service.prepareWorkTarget, // 与 saveWorkFile 同源,⛔ 不得在此另写一份随机命名)。 const target = await service.upload.prepareWorkTarget({ group, folderName: teamFolder, filename: fileName, fileType: file_type || 'word', aggregationUid: aggregation.aggregation_uid, }); // 先合并到临时名,再原子 rename 到最终名 —— 避免管理员在合并过程中下载到半截文件 mergedTmpPath = path.join( target.dir, `.merge_${Date.now()}_${Math.random().toString(36).substring(2, 8)}${path.extname(target.fileName)}` ); // 🔴 文件 IO 并发护栏:分片合并是重 IO,与 uploadChunk 共享同一配额, // 极端并发(无带宽限制 + 单 worker)下被统一限流,避免 fd/事件循环耗尽雪崩。 try { await acquireFileIo(); } catch (e) { ctx.status = 429; ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试'); return; } try { // 合并分片(落到临时名) await service.upload.mergeFileChunks(chunkFiles, mergedTmpPath); // 🔴 删除临时目录:best-effort,**绝不能让清理失败把已成功的合并判成 500**。 // 旧代码是裸 fs.rmdirSync,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会 // 抛到外层 catch → 返回「分片合并失败」,而目标文件已落盘且 DB 记录还没写, // 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。 ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' }); // 原子落位 + 计算 size/md5(filePath 为相对 app/public 的正斜杠路径) const fileInfo = await service.upload.commitWorkFile(mergedTmpPath, target); mergedRelPath = fileInfo.filePath; // 供 catch 回滚孤儿文件用 // 保存提交记录 const record = await service.upload.saveSubmissionRecord( aggregation.aggregation_uid, group, fileInfo, work_name || fileName, file_type || 'word' ); // 入库成功即置位:此后异常不得再删该文件(已被 DB 引用) recordPersisted = true; ctx.body = ctx.helper.success({ id: record.id, workName: record.work_name, filePath: fileInfo.filePath, fileUrl: ctx.helper.fileUrl(fileInfo.filePath), fileName: fileInfo.fileName, fileSize: fileInfo.fileSize, md5: fileInfo.md5, uploadTime: record.upload_time || new Date().toISOString(), }, '分片合并成功'); } finally { releaseFileIo(); } } catch (err) { // 🔴 孤儿回滚(C9 · R45):合并结果已落盘但记录未入库 ⇒ 清掉该文件。语义同 upload()。 if (mergedRelPath && !recordPersisted) { service.upload.removePublicFile(mergedRelPath, 'merge-rollback'); } // 合并中途失败 ⇒ 临时名文件也必须清掉(best-effort,绝不掩盖真实错误) if (mergedTmpPath && !mergedRelPath) { ctx.helper.safeCleanup(mergedTmpPath, { label: 'mergeChunks-failed' }); } ctx.logger.error('分片合并失败:', err); ctx.body = ctx.helper.error(500, '分片合并失败'); } } /** * 查询分片上传进度 * GET /api/upload/:group/chunk-status?fileId=xxx */ // @no-temp-cleanup: 本方法**只读探测**临时目录(fs.existsSync + readdirSync), // 不创建也不删除任何文件 ⇒ 「作用域内无清理」是正确形态,不是缺口。 async chunkStatus() { const { ctx, app } = this; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; const { fileId } = ctx.query; if (!fileId) { ctx.body = ctx.helper.error(400, '缺少 fileId 参数'); return; } // 检查临时目录 const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId); if (!fs.existsSync(tempDir)) { ctx.body = ctx.helper.success({ received: 0, chunks: [], }, '尚未收到分片'); return; } const chunkFiles = fs.readdirSync(tempDir) .filter(f => f.startsWith('chunk_')) .sort(); ctx.body = ctx.helper.success({ received: chunkFiles.length, chunks: chunkFiles.map(f => f.replace('chunk_', '')), }, '获取分片状态成功'); } catch (err) { ctx.logger.error('查询分片状态失败:', err); ctx.body = ctx.helper.error(500, '查询分片状态失败'); } } /** * 查询当前用户的提交状态 * GET /api/upload/:group/status */ async status() { const { ctx, service } = this; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; // 获取用户聚合信息 const aggregation = await this._getUserAggregation(); if (!aggregation) { ctx.body = ctx.helper.error(403, '未找到您的报名信息'); return; } // 检查提交状态(本人)+ 组内互斥状态(R190) const [submitted, teamState, deadlineCheck] = await Promise.all([ service.upload.hasSubmitted(aggregation.aggregation_uid, group), service.upload.getTeamUploadState(aggregation, group), service.upload.validateDeadline(group), ]); // 🔴 组锁状态必须暴露给前端,否则被锁成员会看到「未上传」却任何操作都失败, // 完全不知道原因(同一类「状态不可见」缺陷在考试组锁上已踩过)。 const canUpload = deadlineCheck.valid && !teamState.locked; ctx.body = ctx.helper.success({ submitted, // 本人是否已上传 deadline: deadlineCheck.deadline, canSubmit: canUpload, // 兼容旧字段:语义 = 现在能不能上传 canUpload, deadlineValid: deadlineCheck.valid, // ---- 小组维度 ---- teamNumber: teamState.teamNumber, // 人类可读小组编号(teams.team_number || team_id) folderName: teamState.folderName, // 后端落盘用的目录名(前端只展示,不参与命名) memberCount: teamState.memberCount, teamSubmitted: teamState.hasAnySubmission, // 组内是否已有人上传 teamOwnerName: teamState.owner ? teamState.owner.name : null, isTeamOwner: teamState.isOwner, teamLocked: teamState.locked, // true = 组内别人已上传,本人不能再传 teamLockMessage: teamState.lockMessage, teamFiles: teamState.teamFiles, // 组内已上传文件清单(含上传者) }, '获取提交状态成功'); } catch (err) { ctx.logger.error('查询提交状态失败:', err); ctx.body = ctx.helper.error(500, '查询提交状态失败'); } } /** * 删除已上传的作品文件 * DELETE /api/upload/:group/:id * * 仅允许删除当前登录用户本人的提交记录,并同步删除物理文件。 */ async delete() { const { ctx, service } = this; try { const { group, id } = ctx.params; if (!this._validateGroup(group)) return; const aggregation = await this._getUserAggregation(); if (!aggregation) { ctx.body = ctx.helper.error(403, '未找到您的报名信息'); return; } const numId = parseInt(id, 10); if (!Number.isInteger(numId)) { ctx.body = ctx.helper.error(400, '无效的文件 ID'); return; } const ok = await service.upload.deleteSubmission(aggregation.aggregation_uid, group, numId); if (!ok) { ctx.body = ctx.helper.error(404, '未找到该文件或无权删除'); return; } ctx.body = ctx.helper.success({ id: numId }, '文件已删除'); } catch (err) { ctx.logger.error('删除提交文件失败:', err); ctx.body = ctx.helper.error(500, '删除失败'); } } /** * 查询当前用户的提交历史 * GET /api/upload/:group/submissions */ async submissions() { const { ctx, service } = this; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; // 获取用户聚合信息 const aggregation = await this._getUserAggregation(); if (!aggregation) { ctx.body = ctx.helper.error(403, '未找到您的报名信息'); return; } // 获取提交历史 const submissionList = await service.upload.getSubmissions( aggregation.aggregation_uid, group ); ctx.body = ctx.helper.success({ list: submissionList, total: submissionList.length, }, '获取提交历史成功'); } catch (err) { ctx.logger.error('查询提交历史失败:', err); ctx.body = ctx.helper.error(500, '查询提交历史失败'); } } } module.exports = UploadController;