diff --git a/app/controller/admin.js b/app/controller/admin.js index 8334f26..f40710a 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -558,8 +558,60 @@ class AdminController extends Controller { } } - // ==================== 时间管理 ==================== + // ==================== 单小组作品压缩包(R190) ==================== + /** + * 下载「单个小组」的作品压缩包 + * GET /api/admin/team-zip/:group?teamUid=xx 或 ?aggregationUid=xx + * + * 【业务来源】「管理人员下载作品时将这个文件夹进行压缩,直接下载压缩包」 + * 包内**平铺**该小组的文件(zip 本身即小组目录),文件名保留下传时的原名。 + * + * @returns {Stream} zip 流 + */ + async teamZip() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + try { + const { group } = ctx.params; + // 只有本科/教师组有作品文件(中职/高职组走答题答案通道) + if (!['benke', 'jiaoshi'].includes(group)) { + ctx.body = ctx.helper.error(400, '该组别无作品文件,仅本科组/教师组支持'); + return; + } + + const zipInfo = await service.admin.teamZip(group, { + teamUid: ctx.query.teamUid, + aggregationUid: ctx.query.aggregationUid, + }); + + await sendFile(ctx, this.app, { + absolutePath: zipInfo.zipFilePath, + fileName: zipInfo.downloadName, + contentType: 'application/zip', + useXAccel: false, + }); + + // 下载完成后清理临时 zip(Node 流模式下 res.finish 在流发完后触发) + ctx.res.on('finish', () => { + ctx.helper.safeCleanup(zipInfo.zipFilePath, { label: 'teamZip-zipFile' }); + }); + } catch (err) { + if (err.bizCode === 'EMPTY') { + ctx.body = ctx.helper.error(404, '该小组暂无已上传的作品文件'); + return; + } + if (err.bizCode === 'NOT_FOUND') { + ctx.body = ctx.helper.error(404, err.message || '未找到该小组'); + return; + } + ctx.logger.error('小组作品打包失败:', err); + ctx.body = ctx.helper.error(500, '小组作品打包失败'); + } + } + + // ==================== 时间管理 ==================== /** * 批量设置比赛时间 * PUT /api/admin/times diff --git a/app/controller/upload.js b/app/controller/upload.js index 599ba21..d68af00 100644 --- a/app/controller/upload.js +++ b/app/controller/upload.js @@ -107,6 +107,25 @@ class UploadController extends Controller { 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) { @@ -132,15 +151,18 @@ class UploadController extends Controller { return; } - // 保存文件 - const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group); - savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用 - - // 获取作品名称(流式模式字段在 stream.fields 中) + // 获取作品名称与文件类型(流式模式字段在 stream.fields 中)。 + // ⚠️ 必须先取再落盘:落盘要用 file_type 判定「同名文件是否同一次提交的新版本」。 const workName = (stream.fields && stream.fields.work_name) || stream.filename; - // 文件类型:word / ppt(前端上传时透传) 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, @@ -267,6 +289,8 @@ class UploadController extends Controller { const { ctx, service, app } = this; // 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。 let mergedRelPath = null; + // 合并临时文件(先写临时名再原子 rename)。失败时需清掉,否则留下 `.merge_*` 残渣。 + let mergedTmpPath = null; let recordPersisted = false; try { @@ -293,6 +317,22 @@ class UploadController extends Controller { 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)) { @@ -312,16 +352,20 @@ class UploadController extends Controller { 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); + // 构建落盘目标:小组目录 + 原始文件名(唯一命名实现 = 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/事件循环耗尽雪崩。 @@ -334,8 +378,8 @@ class UploadController extends Controller { } try { - // 合并分片 - const mergeResult = await service.upload.mergeFileChunks(chunkFiles, targetPath); + // 合并分片(落到临时名) + await service.upload.mergeFileChunks(chunkFiles, mergedTmpPath); // 🔴 删除临时目录:best-effort,**绝不能让清理失败把已成功的合并判成 500**。 // 旧代码是裸 fs.rmdirSync,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会 @@ -343,17 +387,11 @@ class UploadController extends Controller { // 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。 ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' }); - // 构建相对路径 - const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/'); - mergedRelPath = relativePath; // 供 catch 回滚孤儿文件用 + // 原子落位 + 计算 size/md5(filePath 为相对 app/public 的正斜杠路径) + const fileInfo = await service.upload.commitWorkFile(mergedTmpPath, target); + mergedRelPath = fileInfo.filePath; // 供 catch 回滚孤儿文件用 // 保存提交记录 - const fileInfo = { - filePath: relativePath, - fileName, - fileSize: mergeResult.fileSize, - md5: mergeResult.md5, - }; const record = await service.upload.saveSubmissionRecord( aggregation.aggregation_uid, group, @@ -367,11 +405,11 @@ class UploadController extends Controller { 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, + 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 { @@ -382,6 +420,10 @@ class UploadController extends Controller { 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, '分片合并失败'); } @@ -448,16 +490,33 @@ class UploadController extends Controller { return; } - // 检查提交状态 - const submitted = await service.upload.hasSubmitted(aggregation.aggregation_uid, group); + // 检查提交状态(本人)+ 组内互斥状态(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 deadlineCheck = await service.upload.validateDeadline(group); + // 🔴 组锁状态必须暴露给前端,否则被锁成员会看到「未上传」却任何操作都失败, + // 完全不知道原因(同一类「状态不可见」缺陷在考试组锁上已踩过)。 + const canUpload = deadlineCheck.valid && !teamState.locked; ctx.body = ctx.helper.success({ - submitted, + submitted, // 本人是否已上传 deadline: deadlineCheck.deadline, - canSubmit: deadlineCheck.valid, + 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); diff --git a/app/lib/adminPermissions.js b/app/lib/adminPermissions.js index 7081e31..0f25006 100644 --- a/app/lib/adminPermissions.js +++ b/app/lib/adminPermissions.js @@ -107,6 +107,8 @@ const ROUTE_TABLE = [ ['GET', '/api/admin/submissions/:group/:id/word', ['submissions']], ['GET', '/api/admin/works/:group', ['submissions']], ['GET', '/api/admin/batch-download/:group', ['submissions']], + // R190 单小组作品压缩包:与批量下载同属「作品管理」能力,同权限即可 + ['GET', '/api/admin/team-zip/:group', ['submissions']], // ---------- 成绩管理(阅卷/导出/公示表) ---------- ['GET', '/api/admin/scores/:group', ['scores', 'review']], diff --git a/app/lib/team_folder.js b/app/lib/team_folder.js new file mode 100644 index 0000000..40af6ea --- /dev/null +++ b/app/lib/team_folder.js @@ -0,0 +1,132 @@ +'use strict'; + +/** + * 小组作品目录命名 —— **单一 SoT** + * + * 【业务来源】R190 管理人员反馈: + * 「参赛小组上传作品时由于他们的作品分为两个文件且会自行命名,所以进行评分时 + * 两个文件分开下载经常出现混乱,现在提出了想法,当参赛小组上传作品时,后台 + * 自动生成一个以他们小组编号命名的文件夹,将他们的作品存放在这个文件夹中。」 + * + * 【本模块的唯一职责】把「一个小组」映射成一个**安全、可读、稳定**的目录名。 + * · 上传落盘(service/upload.js)与管理端打包(service/admin.js)**必须**都调这里 + * —— 否则「上传写进 A 目录、下载从 B 目录找」的错位会在上线后立刻炸。 + * · 前端**不参与**命名,只展示后端回传的 folderName / teamNumber。 + * + * 🔴 与既有口径的关系(⛔ 不得另立第二套): + * 小组编号的展示值沿用 `service/admin.js#resolveTeamLabels` 的 + * `teams.team_number || teams.team_id`;本模块只负责把它**变成合法目录名**, + * 不重新定义「哪个字段算编号」。 + * + * 🔴 为什么必须净化:目录名直接参与 `fs.mkdir` / `path.join`。 + * `team_number` 来自报名表导入,历史上出现过带 `/` `\` 的值会被当成路径分隔符 + * ⇒ 文件落到意料之外的目录(甚至穿越到 works 之外)。故: + * · 替换 Windows 非法字符 `\ / : * ? " < > |` 与控制字符 + * · 去掉首尾空白与首尾点(Windows 不允许尾点,且 `..` 是穿越向量) + * · 限长 60 字符(避免超过单段 255 字节上限,也给中文留余量) + */ + +/** 单段目录名/文件名主体的最大长度(字符数,非字节) */ +const MAX_SEGMENT = 60; + +/** 非法字符:Windows 保留字符 + 控制字符 */ +// eslint-disable-next-line no-control-regex +const ILLEGAL_CHARS = /[\\/:*?"<>|\u0000-\u001f\u007f]/g; + +/** Windows 设备保留名(不区分大小写,含扩展名前的部分) */ +const RESERVED_NAMES = new Set([ + 'con', 'prn', 'aux', 'nul', + 'com0', 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', + 'lpt0', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9', +]); + +/** + * 净化单个路径段(目录名或文件名主体)。 + * + * @param {*} raw - 原始值(任意类型,null/undefined 视为空) + * @param {string} [fallback] - 净化后为空时的兜底值 + * @returns {string} 安全的单段名称 + */ +function sanitizeSegment(raw, fallback = '未命名') { + let s = String(raw === null || raw === undefined ? '' : raw); + s = s.replace(ILLEGAL_CHARS, '_'); + // 折叠空白(含全角空格转半角后折叠),去掉首尾空白 + s = s.replace(/\u3000/g, ' ').replace(/\s+/g, ' ').trim(); + // 去掉首部点(`.` / `..` / `...foo` 都会被误判)与尾部点 + s = s.replace(/^\.+/, '').replace(/\.+$/, '').trim(); + if (s.length > MAX_SEGMENT) s = s.slice(0, MAX_SEGMENT).trim(); + if (!s) return fallback; + // 保留名兜底:`CON` → `CON_` + if (RESERVED_NAMES.has(s.toLowerCase())) return s + '_'; + return s; +} + +/** + * 净化一个**文件名**(保留扩展名,主体走 sanitizeSegment)。 + * + * @param {*} raw - 原始文件名(可能带路径) + * @param {string} [fallbackStem] - 主体为空时的兜底 + * @param {string} [fallbackExt] - 无扩展名时的兜底扩展名(含点,如 '.pdf') + * @returns {string} 安全文件名 + */ +function sanitizeFileName(raw, fallbackStem = '作品', fallbackExt = '') { + const original = String(raw === null || raw === undefined ? '' : raw); + // 只取 basename:同时切开 `/` 与 `\`(path.basename 在 Linux 上不切 `\`) + const base = original.replace(/^.*[\\/]/, ''); + const dot = base.lastIndexOf('.'); + const hasExt = dot > 0 && dot < base.length - 1; + const rawStem = hasExt ? base.slice(0, dot) : base; + const rawExt = hasExt ? base.slice(dot + 1) : ''; + + // 扩展名只允许字母数字,最长 10 位;出现异常字符即视为无扩展名 + const ext = /^[A-Za-z0-9]{1,10}$/.test(rawExt) ? '.' + rawExt.toLowerCase() : fallbackExt; + const stem = sanitizeSegment(rawStem, fallbackStem); + return stem + ext; +} + +/** + * 解析「小组作品目录名」—— 上传落盘与管理端打包共用的唯一入口。 + * + * 三态优先级(与 `resolveTeamLabels` 的 teamNumber 口径对齐): + * ① `teamNumber`(= `teams.team_number || teams.team_id`,人类可读编号,如 BRICS2026-ST-027-94001) + * ② 无编号但有队伍归属 ⇒ `未编组_`(同队成员仍归到同一目录) + * ③ 连队伍归属都没有(单人参赛 / 历史脏数据)⇒ `未编组_`(每人独立,防撞车) + * + * @param {Object} p + * @param {?string} p.teamNumber - 人类可读小组编号 + * @param {?number} p.teamUid - 队伍号(组内唯一) + * @param {?number} p.aggregationUid - 聚合报名 ID(最终兜底) + * @returns {string} 安全的目录名(保证非空、无路径分隔符) + */ +function resolveTeamFolderName({ teamNumber, teamUid, aggregationUid } = {}) { + const label = teamNumber === null || teamNumber === undefined ? '' : String(teamNumber).trim(); + if (label) return sanitizeSegment(label, '未编组'); + + if (teamUid !== null && teamUid !== undefined && String(teamUid).trim() !== '') { + return sanitizeSegment(`未编组_${teamUid}`, '未编组'); + } + if (aggregationUid !== null && aggregationUid !== undefined && String(aggregationUid).trim() !== '') { + return sanitizeSegment(`未编组_${aggregationUid}`, '未编组'); + } + return '未编组'; +} + +/** + * 拼接「小组作品目录」的相对路径(相对 `app/public`,正斜杠)。 + * + * @param {string} group - 组别 + * @param {string} folderName - resolveTeamFolderName 的返回值 + * @returns {string} 如 `works/benke/BRICS2026-ST-027-94001` + */ +function teamFolderRelative(group, folderName) { + return ['works', sanitizeSegment(group, 'unknown'), sanitizeSegment(folderName, '未编组')] + .join('/'); +} + +module.exports = { + MAX_SEGMENT, + sanitizeSegment, + sanitizeFileName, + resolveTeamFolderName, + teamFolderRelative, +}; diff --git a/app/router.js b/app/router.js index ee85b22..0b0a4d0 100644 --- a/app/router.js +++ b/app/router.js @@ -160,6 +160,10 @@ module.exports = app => { router.get('/api/admin/competition-item', adminAuth, controller.admin.competitionItemInfo); router.put('/api/admin/competition-item', adminAuth, controller.admin.updateCompetitionItemInfo); router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload); + // 单小组作品压缩包(R190):zip 本身即小组目录,包内平铺该组文件。 + // ⚠️ 必须注册在 `/api/admin/works/:group` 之前不是硬要求(路径段数不同,不冲突), + // 但保持「同一业务相邻登记」,避免后来者以为是两条无关通道。 + router.get('/api/admin/team-zip/:group', adminAuth, controller.admin.teamZip); router.get('/api/admin/works/:group', adminAuth, controller.admin.works); // 时间设置 diff --git a/app/service/admin.js b/app/service/admin.js index c6bfd28..f032751 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -16,6 +16,12 @@ const { requireTestPassword } = require('../lib/passwordPolicy'); // R107(2026-10-12):身份证号前置校验 —— 把「非 18 位」从 Sequelize 的 // `Validation len on id_number failed` 变成可读的行级原因;同源口径见 lib/idCard.js。 const { inspectIdNumber } = require('../lib/idCard'); +// 🔴 R190:小组作品目录命名的**唯一 SoT**。上传落盘(service/upload.js)与本文件的 +// 批量/单组打包**必须**用同一个函数算目录名,否则「上传写进 A、下载从 B 找」。 +const { + resolveTeamFolderName, + teamFolderRelative, +} = require('../lib/team_folder'); /** * 客观题(选择题 / 判断题)每小题固定分值。 @@ -1244,11 +1250,26 @@ answeredCount, const isExam = group === 'zhongzhi' || group === 'gaozhi'; const contestants = await ctx.model.Contestant.findAll({ where: { group }, - attributes: ['id', 'name', 'id_number', 'phone', 'group', 'word_score', 'ppt_score', 'exam_score'], + // team_id 供 resolveTeamLabels 在「aggregation.team_uid 缺失」时兜底 + attributes: ['id', 'name', 'id_number', 'phone', 'group', 'team_id', 'word_score', 'ppt_score', 'exam_score'], order: [['id', 'ASC']], raw: true, }); + // 🔴 R190:小组编号 / 小组目录名(口径唯一来源,⛔ 不得在此另算一套) + const labelMap = await resolveTeamLabels(ctx, contestants); + const teamInfoOf = (contestantId, aggUid) => { + const label = labelMap.get(String(contestantId)) || {}; + const teamUid = label.teamUid === undefined ? null : label.teamUid; + const teamNumber = label.teamNumber || null; + return { + teamUid, + teamNumber, + // 后端落盘用的目录名;前端只展示,⛔ 不参与拼接 + teamFolder: resolveTeamFolderName({ teamNumber, teamUid, aggregationUid: aggUid }), + }; + }; + if (!isExam) { const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission; const rows = await SubmissionModel.findAll({ order: [['upload_time', 'DESC']], raw: true }); @@ -1266,7 +1287,8 @@ answeredCount, const byAgg = {}; rows.forEach(r => { (byAgg[r.aggregation_uid] = byAgg[r.aggregation_uid] || []).push(r); }); return contestants.map(c => { - const subs = byAgg[contestantToAgg[c.id]] || []; + const aggUid = contestantToAgg[c.id]; + const subs = byAgg[aggUid] || []; const files = subs.map(s => ({ id: s.id, kind: s.file_type || 'word', @@ -1277,7 +1299,16 @@ answeredCount, uploadedAt: s.upload_time, scoreField: s.file_type === 'ppt' ? 'ppt' : 'word', })); - return { contestantId: c.id, name: c.name, idNumber: c.id_number, phone: c.phone, group: c.group, files }; + return { + contestantId: c.id, + name: c.name, + idNumber: c.id_number, + phone: c.phone, + group: c.group, + aggregationUid: aggUid === undefined ? null : aggUid, + ...teamInfoOf(c.id, aggUid), + files, + }; }); } @@ -1304,7 +1335,15 @@ answeredCount, uploadedAt: null, scoreField: 'word', }] : []; - return { contestantId: c.id, name: c.name, idNumber: c.id_number, phone: c.phone, group: c.group, files }; + return { + contestantId: c.id, + name: c.name, + idNumber: c.id_number, + phone: c.phone, + group: c.group, + ...teamInfoOf(c.id, null), + files, + }; }); } @@ -1608,15 +1647,52 @@ answeredCount, } // ==================== 批量下载 ZIP ==================== + /** + * 把「zip 条目清单」打成压缩包(单一打包实现,批量与单组共用)。 + * + * @param {Array<{absPath:string, zipName:string}>} entries - 条目清单 + * @returns {Promise} zip 绝对路径 + * @private + */ + async _buildZip(entries) { + const { app } = this; + // 使用 archiver 打包。archiver v8 为 ESM,导出 ZipArchive 等类;用 new ZipArchive(...) 实例化 + const { ZipArchive } = require('archiver'); + const zipFilePath = path.join(app.baseDir, 'app/public/tmp/zip', `zip_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.zip`); + fs.mkdirSync(path.dirname(zipFilePath), { recursive: true }); + + const output = fs.createWriteStream(zipFilePath); + const archive = new ZipArchive({ zlib: { level: 9 } }); + + await new Promise((resolve, reject) => { + output.on('close', resolve); + archive.on('error', reject); + archive.pipe(output); + // 🔴 直接 file() 到目标条目名,⛔ 不再先 copyFileSync 到临时目录再按目录遍历: + // 复制步骤既多一次全量磁盘写,也会让「重名文件」在临时目录里互相覆盖。 + for (const e of entries) { + archive.file(e.absPath, { name: e.zipName }); + } + archive.finalize(); + }); + + return zipFilePath; + } + /** * 批量下载某组所有提交文件,打包为 ZIP - * 文件重命名为 "组号-学校-姓名-原文件名" 格式 + * + * 🔴 R190 改造:本科/教师组**按小组分文件夹**打包 —— + * 旧实现把所有文件平铺在 zip 根下,命名为 `姓名-word-原名`, + * 两个文件(Word/PPT)混在一堆同类文件里,评分时无法判断哪两个属于同一小组。 + * 新结构:`<小组编号>/<原始文件名>`,与管理端「单个小组压缩包」完全同构。 + * 中职/高职组(Word 答题答案)保持原有平铺命名,不受影响。 * * @param {string} group - 组别 * @returns {Promise} ZIP 文件路径 */ async batchDownloadZip(group) { - const { ctx, app } = this; + const { app } = this; // 获取该组的所有提交记录(考试组与作品组数据形状不同) const isExam = group === 'zhongzhi' || group === 'gaozhi'; @@ -1624,14 +1700,32 @@ answeredCount, ? await this.getExamSubmissions(group) : await this.getSubmissions(group); - // 构建临时 ZIP 目录 - const zipDir = path.join(app.baseDir, 'app/public/tmp/zip', group, String(Date.now())); - if (!fs.existsSync(zipDir)) { - fs.mkdirSync(zipDir, { recursive: true }); + // works 列表提供「选手 → 小组目录名」映射(口径与上传落盘同源) + const worksList = isExam ? [] : await this.getWorks(group); + const folderByContestant = new Map(); + for (const w of worksList) { + folderByContestant.set(String(w.contestantId), w.teamFolder); } - // 收集所有需要打包的文件 - const filesToZip = []; + const entries = []; + const usedNames = new Set(); + /** 同名条目去重(历史数据里同组两人都传过时会撞名) */ + const uniqueZipName = (desired) => { + if (!usedNames.has(desired)) { usedNames.add(desired); return desired; } + const slash = desired.lastIndexOf('/'); + const dirPart = slash >= 0 ? desired.slice(0, slash + 1) : ''; + const base = slash >= 0 ? desired.slice(slash + 1) : desired; + const dot = base.lastIndexOf('.'); + const stem = dot > 0 ? base.slice(0, dot) : base; + const ext = dot > 0 ? base.slice(dot) : ''; + for (let i = 2; i <= 99; i += 1) { + const cand = `${dirPart}${stem}-${i}${ext}`; + if (!usedNames.has(cand)) { usedNames.add(cand); return cand; } + } + const cand = `${dirPart}${stem}-${Date.now()}${ext}`; + usedNames.add(cand); + return cand; + }; // 本科/教师组需要 account.name_cn;考试组使用 getExamSubmissions 的 userName for (const item of submissions) { @@ -1639,66 +1733,107 @@ answeredCount, const userName = (isExam ? (item.userName || '未知') : (account.name_cn || '未知')).replace(/[\\/:*?"<>|]/g, '_'); if (isExam) { - // 中职/高职组:收集 Word 答案文件 + // 中职/高职组:收集 Word 答案文件(保持原平铺命名) if (item.wordFilePath) { const fullPath = path.join(app.baseDir, 'app/public', item.wordFilePath); if (fs.existsSync(fullPath)) { const origName = item.wordFileName || path.basename(fullPath); - const targetName = `${userName}-${origName}`; - const targetPath = path.join(zipDir, targetName); - fs.copyFileSync(fullPath, targetPath); - filesToZip.push({ srcPath: fullPath, zipName: targetName }); + entries.push({ absPath: fullPath, zipName: uniqueZipName(`${userName}-${origName}`) }); } } } else { - // 本科/教师组:收集 Word + PPT 作品文件 + // 本科/教师组:按小组目录归集 Word + PPT + const folder = folderByContestant.get(String(item.contestantId)) || '未编组'; for (const sub of (item.workSubmissions || [])) { - if (sub.file_path) { - const fullPath = path.join(app.baseDir, 'app/public', sub.file_path); - if (fs.existsSync(fullPath)) { - const origName = sub.file_name || path.basename(fullPath); - const targetName = `${userName}-${sub.file_type || 'file'}-${origName}`; - const targetPath = path.join(zipDir, targetName); - fs.copyFileSync(fullPath, targetPath); - filesToZip.push({ srcPath: fullPath, zipName: targetName }); - } - } + if (!sub.file_path) continue; + const fullPath = path.join(app.baseDir, 'app/public', sub.file_path); + if (!fs.existsSync(fullPath)) continue; + const origName = (sub.file_name || path.basename(fullPath)).replace(/[\\/:*?"<>|]/g, '_'); + entries.push({ absPath: fullPath, zipName: uniqueZipName(`${folder}/${origName}`) }); } } } - // 创建 ZIP 文件 - const zipFileName = `${group}_submissions_${Date.now()}.zip`; - const zipFilePath = path.join(app.baseDir, 'app/public/tmp/zip', zipFileName); + if (entries.length === 0) { + const err = new Error('该组暂无已上传的作品文件'); + err.bizCode = 'EMPTY'; + throw err; + } - // 使用 archiver 将收集到的文件打包为 ZIP - // archiver v8 为 ESM,导出 ZipArchive 等类;用 new ZipArchive(...) 实例化 - const { ZipArchive } = require('archiver'); - const output = fs.createWriteStream(zipFilePath); - const archive = new ZipArchive({ zlib: { level: 9 } }); + return await this._buildZip(entries); + } - await new Promise((resolve, reject) => { - output.on('close', resolve); - archive.on('error', reject); + /** + * 单小组作品压缩包(R190) + * + * 【业务来源】「管理人员下载作品时将这个文件夹进行压缩,直接下载压缩包」 + * ⇒ 单个小组 = 一个压缩包,包内**平铺**该小组的两个文件(不再套一层目录)。 + * + * @param {string} group - benke / jiaoshi + * @param {Object} p + * @param {?number} p.teamUid - 队伍号(优先) + * @param {?number} p.aggregationUid - 聚合报名 ID(无队伍号时的定位键) + * @returns {Promise<{zipFilePath:string, downloadName:string, folderName:string, fileCount:number}>} + */ + async teamZip(group, { teamUid = null, aggregationUid = null } = {}) { + const { app } = this; + const works = await this.getWorks(group); - archive.pipe(output); - - // 将重命名后的文件添加到 ZIP - const copiedFiles = fs.readdirSync(zipDir); - for (const file of copiedFiles) { - const filePath = path.join(zipDir, file); - archive.file(filePath, { name: file }); + // 定位本小组的成员行:有 teamUid 用 teamUid(组内全部成员),否则用 aggregationUid + let target; + if (teamUid !== null && teamUid !== undefined && String(teamUid) !== '') { + const members = works.filter(w => String(w.teamUid) === String(teamUid)); + if (members.length === 0) { + const err = new Error('未找到该小组'); + err.bizCode = 'NOT_FOUND'; + throw err; } + target = members; + } else { + const one = works.find(w => String(w.aggregationUid) === String(aggregationUid)); + if (!one) { + const err = new Error('未找到该小组的报名信息'); + err.bizCode = 'NOT_FOUND'; + throw err; + } + target = [one]; + } - archive.finalize(); - }); + const folderName = target[0].teamFolder || '未编组'; + const entries = []; + const usedNames = new Set(); + for (const w of target) { + for (const f of (w.files || [])) { + if (!f.filePath) continue; + const abs = path.join(app.baseDir, 'app/public', f.filePath); + if (!fs.existsSync(abs)) continue; + let name = (f.fileName || path.basename(abs)).replace(/[\\/:*?"<>|]/g, '_'); + if (usedNames.has(name)) { + const dot = name.lastIndexOf('.'); + const stem = dot > 0 ? name.slice(0, dot) : name; + const ext = dot > 0 ? name.slice(dot) : ''; + let i = 2; + while (usedNames.has(`${stem}-${i}${ext}`)) i += 1; + name = `${stem}-${i}${ext}`; + } + usedNames.add(name); + entries.push({ absPath: abs, zipName: name }); + } + } - // 🔴 清理临时目录:best-effort(同源缺陷族,与 controller/upload.js:mergeChunks 同修)。 - // ZIP 已 finalize 落盘,清理失败绝不能把成功的批量下载判成 500 - // (common-error.log 实证:/api/admin/batch-download/* 曾因此整体失败)。 - this.ctx.helper.safeCleanup(zipDir, { label: 'batchZip-tmp' }); + if (entries.length === 0) { + const err = new Error('该小组暂无已上传的作品文件'); + err.bizCode = 'EMPTY'; + throw err; + } - return zipFilePath; + const zipFilePath = await this._buildZip(entries); + return { + zipFilePath, + downloadName: `${folderName}_作品.zip`, + folderName, + fileCount: entries.length, + }; } // ==================== 评分管理 ==================== diff --git a/app/service/upload.js b/app/service/upload.js index 8dc0c00..9d30f76 100644 --- a/app/service/upload.js +++ b/app/service/upload.js @@ -4,6 +4,11 @@ const Service = require('egg').Service; const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); +const { + sanitizeSegment, + sanitizeFileName, + resolveTeamFolderName, +} = require('../lib/team_folder'); /** * 上传服务 - UploadService @@ -73,68 +78,384 @@ class UploadService extends Service { }; } + // ==================== 小组(队伍)上下文 ==================== + /** - * 保存作品文件 + * 解析当前提交者所属「小组」的上下文 —— 上传落盘与管理端打包共用的唯一解析入口。 + * + * 🔴 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号)。 + * teamUid 解析优先级与 `service/admin.js#resolveTeamLabels` 完全同源: + * `aggregation.team_uid`(报名表聚合,权威)→ `contestants.team_id`(兜底) + * + * @param {Object} aggregation - getUserAggregation() 的返回值(含 include team) + * @param {string} group - 组别 + * @returns {Promise<{teamUid:?number, teamNumber:?string, folderName:string, contestant:?Object}>} + */ + async resolveTeamContext(aggregation, group) { + const { ctx } = this; + if (!aggregation) { + return { teamUid: null, teamNumber: null, folderName: resolveTeamFolderName({}), contestant: null }; + } + + // 选手档案:既用于 team_id 兜底,也用于取姓名做提示文案 + const contestant = await ctx.model.Contestant.findByPk(aggregation.account_uid, { + attributes: ['id', 'name', 'group', 'team_id'], + raw: true, + }); + + let teamUid = aggregation.team_uid; + if (teamUid === null || teamUid === undefined) { + teamUid = (contestant && contestant.team_id !== null && contestant.team_id !== undefined) + ? contestant.team_id + : null; + } + + // 队伍元信息:优先用 include 进来的 team;缺失时按 (group, teamUid) 回查, + // 避免「有归属无元信息」时把编号退化成兜底名(那会让同队两人的目录名不一致)。 + let team = aggregation.team || null; + if (!team && teamUid !== null && teamUid !== undefined) { + team = await ctx.model.Team.findOne({ + where: { team_uid: teamUid, group_name: group }, + attributes: ['team_uid', 'group_name', 'team_id', 'team_number'], + raw: true, + }); + } + + const teamNumber = team ? (team.team_number || team.team_id || null) : null; + const folderName = resolveTeamFolderName({ + teamNumber, + teamUid, + aggregationUid: aggregation.aggregation_uid, + }); + + return { teamUid, teamNumber, folderName, contestant }; + } + + /** + * 查询「组内上传状态」—— 本科/教师组的组内互斥判定唯一事实来源。 + * + * 【业务规则(R190)】 + * ① 同一参赛小组内,只要有任一成员上传过作品,其他成员就不能再上传; + * ② 「替换权」只归**组内最早提交者**(归属人),他在截止前可以随时替换; + * ③ 无队伍归属的独立参赛者不参与组内互斥(自己传自己的)。 + * + * ⚠️ 判据必须按 (group, teamUid) 取**全部成员**再数提交记录 —— 旧实现 + * `hasSubmitted()` 只数本人的 `aggregation_uid`,同组他人上传后自己仍可继续传, + * 正是本次要修的缺陷。 + * + * @param {Object} aggregation - getUserAggregation() 的返回值 + * @param {string} group - 组别 + * @returns {Promise} 见下方 result 结构 + */ + async getTeamUploadState(aggregation, group) { + const { ctx } = this; + const Op = ctx.model.Sequelize.Op; + const SubmissionModel = group === 'benke' + ? ctx.model.BenkeSubmission + : ctx.model.JiaoshiSubmission; + + const teamCtx = await this.resolveTeamContext(aggregation, group); + const selfAggUid = aggregation.aggregation_uid; + + const result = { + teamUid: teamCtx.teamUid, + teamNumber: teamCtx.teamNumber, + folderName: teamCtx.folderName, + memberCount: 1, + hasAnySubmission: false, + owner: null, // { aggregationUid, contestantId, name } + isOwner: false, + locked: false, // true = 组内别人已上传,本人不能再传 + lockMessage: null, + teamFiles: [], // [{ fileName, fileType, uploaderName, isMine }] + }; + + // ---- 无队伍归属:独立参赛,组内锁不成立 ---- + if (teamCtx.teamUid === null || teamCtx.teamUid === undefined) { + const mine = await this.hasSubmitted(selfAggUid, group); + result.hasAnySubmission = mine; + result.isOwner = mine; + if (mine) { + result.owner = { + aggregationUid: selfAggUid, + contestantId: aggregation.account_uid, + name: (teamCtx.contestant && teamCtx.contestant.name) || null, + }; + } + return result; + } + + // ---- 取同组同队的全部成员 ---- + // 🔴 必须先按组别过滤再认 teamUid:aggregation.team_uid 是全局序列的弱引用, + // 不加组别限定会把别的组里同号队伍的人算进来(跨组串组,历史上已踩过)。 + const aggRows = await ctx.model.Aggregation.findAll({ + where: { team_uid: teamCtx.teamUid }, + attributes: ['aggregation_uid', 'account_uid'], + raw: true, + }); + const candIds = aggRows.map(a => a.account_uid).filter(v => v !== null && v !== undefined); + const memberContestants = candIds.length + ? await ctx.model.Contestant.findAll({ + where: { id: { [Op.in]: candIds }, group }, + attributes: ['id', 'name'], + raw: true, + }) + : []; + const nameOf = new Map(memberContestants.map(c => [String(c.id), c.name])); + const groupMemberIds = new Set(memberContestants.map(c => String(c.id))); + const members = aggRows.filter(a => groupMemberIds.has(String(a.account_uid))); + result.memberCount = Math.max(members.length, 1); + + // ---- 成员提交记录:按 aggregation_uid 取最早一条,再全局取最早者作归属人 ---- + const memberAggUids = members.map(m => m.aggregation_uid); + let firstByAgg = new Map(); + if (memberAggUids.length > 0) { + const subs = await SubmissionModel.findAll({ + where: { aggregation_uid: { [Op.in]: memberAggUids } }, + attributes: ['id', 'aggregation_uid', 'upload_time', 'file_name', 'file_type'], + raw: true, + }); + for (const s of subs) { + const t = s.upload_time ? new Date(s.upload_time).getTime() : 0; + const cur = firstByAgg.get(String(s.aggregation_uid)); + // 同一 aggregation 有多行(word/ppt)时取最早;同时刻用更小 id 保序,保证可复现 + if (!cur || t < cur.t || (t === cur.t && s.id < cur.id)) { + firstByAgg.set(String(s.aggregation_uid), { t, id: s.id, aggUid: s.aggregation_uid }); + } + // 组内已上传文件清单:让被锁成员**看得见**「组里已经有什么」, + // 而不是只收到一句「你不能传」。 + result.teamFiles.push({ + fileName: s.file_name || '', + fileType: s.file_type || 'word', + uploaderName: nameOf.get(String( + (members.find(m => String(m.aggregation_uid) === String(s.aggregation_uid)) || {}).account_uid + )) || null, + isMine: String(s.aggregation_uid) === String(selfAggUid), + }); + } + // 稳定排序:word 在前、ppt 在后,便于前端固定展示顺序 + result.teamFiles.sort((a, b) => String(a.fileType).localeCompare(String(b.fileType))); + } + + result.hasAnySubmission = firstByAgg.size > 0; + + let best = null; + for (const v of firstByAgg.values()) { + if (!best || v.t < best.t || (v.t === best.t && v.id < best.id)) best = v; + } + + if (best) { + const ownerMember = members.find(m => String(m.aggregation_uid) === String(best.aggUid)); + const ownerContestantId = ownerMember ? ownerMember.account_uid : null; + result.owner = { + aggregationUid: best.aggUid, + contestantId: ownerContestantId, + name: ownerContestantId !== null ? (nameOf.get(String(ownerContestantId)) || null) : null, + }; + result.isOwner = String(best.aggUid) === String(selfAggUid); + result.locked = !result.isOwner; + if (result.locked) { + const who = result.owner.name || '同组其他成员'; + result.lockMessage = + `您的小组已由「${who}」上传作品。为保持组内作品唯一,` + + `只有「${who}」可在截止时间前替换文件,其他成员不能再上传。` + + `如需调整,请联系「${who}」处理。`; + } + } + + return result; + } + + /** + * 组内互斥守卫 —— 上传/合并两条路径**都必须**先过这里。 + * + * @param {Object} aggregation + * @param {string} group + * @returns {Promise<{ok:boolean, message:string, state:Object}>} + */ + async assertTeamUploadAllowed(aggregation, group) { + const state = await this.getTeamUploadState(aggregation, group); + if (state.locked) { + return { ok: false, message: state.lockMessage, state }; + } + return { ok: true, message: '', state }; + } + + // ==================== 作品落盘 ==================== + + /** + * 确保小组作品目录存在并返回绝对路径。 + * + * @param {string} group - 组别 + * @param {string} folderName - 小组目录名 + * @returns {string} 目录绝对路径 + */ + ensureWorkDir(group, folderName) { + const folder = sanitizeSegment(folderName, '未编组'); + const dir = path.join(this.app.baseDir, 'app/public/works', group, folder); + try { + // 无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT + fs.mkdirSync(dir, { recursive: true }); + } catch (mkErr) { + this.app.logger.error('[ensureWorkDir] mkdir failed', { dir, message: mkErr.message }); + throw mkErr; + } + return dir; + } + + /** + * 计算作品落盘目标(目录 + 最终文件名)—— 上传与分片合并共用的**唯一**命名实现。 + * + * 🔴 为什么必须收敛:改造前 `service/upload.js#saveWorkFile` 与 + * `controller/upload.js#mergeChunks` 各写了一份 `work__` 随机命名, + * 两处一旦漂移就会出现「合并落 A、下载找 B」。现统一到本方法。 + * + * 落点:`app/public/works//<小组编号>/<原始文件名>` + * + * @param {Object} p + * @param {string} p.group - 组别 + * @param {string} p.folderName - 小组目录名(resolveTeamFolderName) + * @param {string} p.filename - 上传时的原始文件名 + * @param {string} [p.fileType] - word / ppt + * @param {?number} [p.aggregationUid] - 提交者聚合 ID(同名文件归属判定用) + * @returns {Promise<{dir:string, folder:string, fileName:string, absPath:string, relPath:string}>} + */ + async prepareWorkTarget({ group, folderName, filename, fileType = 'word', aggregationUid = null }) { + const dir = this.ensureWorkDir(group, folderName); + const folder = sanitizeSegment(folderName, '未编组'); + + const fileName = await this._resolveTargetFileName({ + dir, group, folder, filename, fileType, aggregationUid, + }); + const relPath = ['works', group, folder, fileName].join('/'); + + return { dir, folder, fileName, absPath: path.join(dir, fileName), relPath }; + } + + /** + * 决定小组目录内的最终文件名。 + * + * 【规则】保留上传时的原始文件名(用户裁定);仅在**确实会覆盖他人文件**时才加序号。 + * ① 目标不存在 ⇒ 直接用原名 + * ② 目标存在,且 DB 里同路径记录属于**同一聚合、同一 file_type** ⇒ 是同一次提交的新版本 + * ⇒ 用原名(走原子 rename 覆盖,`saveSubmissionRecord` 会清理旧路径) + * ③ 目标存在但查不到记录 / 属于别人 / 属不同 file_type ⇒ 加 `(2)(3)…` 序号,绝不覆盖 + * + * @private + */ + async _resolveTargetFileName({ dir, group, folder, filename, fileType, aggregationUid }) { + const { ctx } = this; + const baseName = sanitizeFileName(filename); + const abs = path.join(dir, baseName); + if (!fs.existsSync(abs)) return baseName; + + const rel = ['works', group, folder, baseName].join('/'); + const SubmissionModel = group === 'benke' + ? ctx.model.BenkeSubmission + : ctx.model.JiaoshiSubmission; + const row = await SubmissionModel.findOne({ + where: { file_path: rel }, + attributes: ['id', 'aggregation_uid', 'file_type'], + raw: true, + }); + + const sameLogicalFile = row + && String(row.file_type || 'word') === String(fileType || 'word') + && (aggregationUid === null || String(row.aggregation_uid) === String(aggregationUid)); + if (sameLogicalFile) return baseName; + + // 撞名且不属同一逻辑文件 ⇒ 加序号,保护已在使用的文件 + const dot = baseName.lastIndexOf('.'); + const stem = dot > 0 ? baseName.slice(0, dot) : baseName; + const ext = dot > 0 ? baseName.slice(dot) : ''; + for (let i = 2; i <= 99; i += 1) { + const candidate = `${stem}(${i})${ext}`; + if (!fs.existsSync(path.join(dir, candidate))) return candidate; + } + // 极端兜底:用时间戳确保唯一(不抛错,宁可名字丑也不丢文件) + return `${stem}_${Date.now()}${ext}`; + } + + /** + * 把临时文件原子地落到最终位置,并补齐 size / md5。 + * + * 🔴 为什么要先写临时名再 rename:直接写最终文件时,若恰好有管理员在下载, + * 会读到**半截文件**(zip 里就是损坏的 PDF)。同目录 rename 在同一文件系统内 + * 是原子操作,读方要么看到旧的完整文件、要么看到新的完整文件。 + * + * @param {string} tmpPath - 临时文件绝对路径(必须与目标同目录) + * @param {Object} target - prepareWorkTarget() 的返回值 + * @returns {Promise<{filePath:string, fileName:string, fileSize:number, md5:string}>} + */ + async commitWorkFile(tmpPath, target) { + try { + fs.renameSync(tmpPath, target.absPath); + } catch (renameErr) { + this.ctx.helper.safeCleanup(tmpPath, { label: 'commitWorkFile-tmp' }); + throw renameErr; + } + const stat = fs.statSync(target.absPath); + const md5 = await this._calculateMd5(target.absPath); + return { + filePath: target.relPath, + fileName: target.fileName, + fileSize: stat.size, + md5, + }; + } + + /** + * 保存作品文件(单次流式上传) + * * @param {ReadableStream} stream - 文件流 * @param {string} filename - 原始文件名 * @param {string} group - 组别 + * @param {string} folderName - 小组目录名(resolveTeamFolderName 的返回值) + * @param {Object} [opts] + * @param {string} [opts.fileType] - word / ppt + * @param {?number} [opts.aggregationUid] - 提交者聚合 ID * @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); + async saveWorkFile(stream, filename, group, folderName, opts = {}) { + const { fileType = 'word', aggregationUid = null } = opts; + const target = await this.prepareWorkTarget({ + group, folderName, filename, fileType, aggregationUid, }); - // 🔴 落盘完整性校验(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} 字节`); + // 临时名:以 `.` 开头 + 固定前缀,便于残留扫描识别;同目录保证 rename 原子 + const tmpName = `.upload_${Date.now()}_${Math.random().toString(36).substring(2, 8)}` + + path.extname(target.fileName); + const tmpPath = path.join(target.dir, tmpName); + + const writeStream = fs.createWriteStream(tmpPath); + let fileSize = 0; + + try { + 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(tmpPath); + if (stat.size !== fileSize) { + // 🔴 清理半截文件用 best-effort:裸 unlinkSync 一旦失败会抛出「删除失败」, + // 把下面那条真正有诊断价值的「文件写入不完整:期望 X 实际 Y」**顶掉**。 + this.ctx.helper.safeCleanup(tmpPath, { label: 'partialFile' }); + throw new Error(`文件写入不完整:期望 ${fileSize} 字节,实际 ${stat.size} 字节`); + } + } catch (err) { + // 任何失败都不留半截临时文件(best-effort,不掩盖真实错误) + this.ctx.helper.safeCleanup(tmpPath, { label: 'saveWorkFile-failed' }); + throw err; } - // 计算文件 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, - }; + return await this.commitWorkFile(tmpPath, target); } /**