'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; 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; } // 获取上传的文件流 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; } // 保存文件 const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group); // 获取作品名称(流式模式字段在 stream.fields 中) const workName = (stream.fields && stream.fields.work_name) || stream.filename; // 文件类型:word / ppt(前端上传时透传) const fileType = (stream.fields && stream.fields.file_type) || 'word'; // 保存提交记录 const record = await service.upload.saveSubmissionRecord( aggregation.aggregation_uid, group, fileInfo, workName, fileType ); 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) { 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 */ 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; 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; } // 检查分片目录 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; } // 构建目标文件路径 const uploadDir = path.join(app.baseDir, 'app/public/works', group); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } const ext = path.extname(fileName); const timestamp = Date.now(); const safeFileName = `work_${timestamp}_${Math.random().toString(36).substring(2, 8)}${ext}`; const targetPath = path.join(uploadDir, safeFileName); // 🔴 文件 IO 并发护栏:分片合并是重 IO,与 uploadChunk 共享同一配额, // 极端并发(无带宽限制 + 单 worker)下被统一限流,避免 fd/事件循环耗尽雪崩。 try { await acquireFileIo(); } catch (e) { ctx.status = 429; ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试'); return; } try { // 合并分片 const mergeResult = await service.upload.mergeFileChunks(chunkFiles, targetPath); // 🔴 删除临时目录:best-effort,**绝不能让清理失败把已成功的合并判成 500**。 // 旧代码是裸 fs.rmdirSync,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会 // 抛到外层 catch → 返回「分片合并失败」,而目标文件已落盘且 DB 记录还没写, // 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。 ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' }); // 构建相对路径 const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/'); // 保存提交记录 const fileInfo = { filePath: relativePath, fileName, fileSize: mergeResult.fileSize, md5: mergeResult.md5, }; const record = await service.upload.saveSubmissionRecord( aggregation.aggregation_uid, group, fileInfo, work_name || fileName, file_type || 'word' ); ctx.body = ctx.helper.success({ id: record.id, workName: record.work_name, filePath: relativePath, fileUrl: ctx.helper.fileUrl(relativePath), fileName, fileSize: mergeResult.fileSize, md5: mergeResult.md5, uploadTime: record.upload_time || new Date().toISOString(), }, '分片合并成功'); } finally { releaseFileIo(); } } catch (err) { ctx.logger.error('分片合并失败:', err); ctx.body = ctx.helper.error(500, '分片合并失败'); } } /** * 查询分片上传进度 * GET /api/upload/:group/chunk-status?fileId=xxx */ 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; } // 检查提交状态 const submitted = await service.upload.hasSubmitted(aggregation.aggregation_uid, group); // 检查截止时间 const deadlineCheck = await service.upload.validateDeadline(group); ctx.body = ctx.helper.success({ submitted, deadline: deadlineCheck.deadline, canSubmit: deadlineCheck.valid, }, '获取提交状态成功'); } 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;