'use strict'; 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 * * 封装作品文件上传相关业务逻辑,包括截止时间校验、文件保存、 * 分片上传与合并等功能。适用于本科组和教师组的作品提交。 */ 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(), }; } // ==================== 小组(队伍)上下文 ==================== /** * 解析当前提交者所属「小组」的上下文 —— 上传落盘与管理端打包共用的唯一解析入口。 * * 🔴 小组键 = (组别 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, folderName, opts = {}) { const { fileType = 'word', aggregationUid = null } = opts; const target = await this.prepareWorkTarget({ group, folderName, filename, fileType, aggregationUid, }); // 临时名:以 `.` 开头 + 固定前缀,便于残留扫描识别;同目录保证 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; } return await this.commitWorkFile(tmpPath, target); } /** * 合并文件分片 * @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) { // 🔴 C9 根因① 修复(2026-09-29 · R45):重传覆盖前,先记住旧物理文件路径, // 待 DB 更新**成功之后**再删旧文件 —— 否则每次重传都把旧文件永久留在磁盘上。 // 实证:本次清理的 1003 个孤儿(9.62 GB)中,绝大多数正是这样累积出来的。 // // ⚠️ 顺序不可颠倒:若先删旧文件、而 update 又失败,DB 仍然指向**已被删除**的旧路径 // ⇒ 管理端/考生端下载 404(这是**数据丢失**,比留下一个孤儿严重得多)。 // 先落库后删文件,最坏结果只是多一个可被兜底扫描捞回来的孤儿。 const stalePath = (existing.file_path && existing.file_path !== fileInfo.filePath) ? existing.file_path : null; await existing.update({ ...payload, score: null, // 重新上传后清除评分 }); if (stalePath) { // best-effort:删不掉只告警,绝不影响「上传成功」这个业务结果 const r = this.removePublicFile(stalePath, 'overwrite-stale'); // 🔴 R157:登记计数(⛔ 不抛错,保持 best-effort),使「旧文件清理失败」 // 在运行期可被读取,而不只沉在日志里。 this._noteDeleteResult(r, stalePath, 'overwrite-stale'); if (r !== 'removed') { this.app.logger.warn( `[saveSubmissionRecord] 旧文件未能清理,已成为孤儿(等待兜底扫描 script/scan_orphan_files.py): ${stalePath} -> ${r}` ); } } 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 } = 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) // 复用 removePublicFile:统一走「前缀白名单 + best-effort」的安全删除路径, // 避免各处各写一份裸 unlink(历史上正是这种分散写法导致清理失败被静默吞掉)。 // // 🔴 R157:删除结果**不再丢弃**。此前返回值被完全忽略 ⇒ 删除失败只进日志, // 运行期无计数、无暴露,孤儿文件在服务侧完全不可见(只能靠离线扫描发现, // 而离线扫描报出的孤儿无法回溯「是哪一次删除失败的」)。 // 修法:累计到**服务实例计数器 + 暴露 getter**,⛔ 不抛错(保持 best-effort 语义)。 if (record.file_path) { const r = this.removePublicFile(record.file_path, 'deleteSubmission'); this._noteDeleteResult(r, record.file_path, 'deleteSubmission'); } await record.destroy(); return true; } /** * 删除 app/public 下的物理文件(统一的安全删除入口) * * 【为什么需要它】C9 根因治理(2026-09-29 · R45):此前「删作品文件」这件事在 * service/upload.js、controller/admin.js、controller/exam.js 各写了一遍裸 * `fs.unlinkSync`,既没有路径白名单、失败也只在个别处记日志 ⇒ * 一旦删除失败就静默留下一份无人认领的文件(孤儿)。 * 现收敛为**唯一入口**:所有「按 DB 里的相对路径删文件」都必须经过这里。 * /** * 【安全闸门】只允许删除 `works/` 与 `tmp/` 前缀下的文件: * - DB 里的 file_path 天然是 `works//`;前缀白名单可挡住 * 「脏数据 / 手工改库 / SQL 注入」把 `file_path` 指向 `templates/`、`carousel/` * 等静态资源时被连带删除的风险。 * - 含 `..` 的路径一律拒绝(防目录穿越)。 * * 【失败语义】best-effort,**永不抛错**:删除是副作用,不能污染主业务结果 * (详见 extend/helper.js:safeCleanup 的说明)。 * * @param {string} relativePath - 相对 app/public 的路径,如 `works/benke/work_x.pdf` * @param {string} [label] - 日志场景名 * @returns {'removed'|'missing'|'skipped'} 删除结果(不抛错) */ /** * 【R157】登记一次删除结果 —— 把「删除失败」从**只有日志**变成**可计数、可读取**。 * * 【为什么不抛错】删除是副作用,失败不得污染主业务结果(best-effort 语义见 * extend/helper.js:safeCleanup)。但「不抛错」⛔ 不等于「不可见」—— * 此前 removed/skipped/missing 三态被调用方直接丢弃,导致孤儿文件在运行期 * 完全不可见。这里累计计数并暴露 `delStats` / `getDeleteStats()`, * 供健康检查、探针与门禁读取。 * * @param {'removed'|'missing'|'skipped'} r - removePublicFile 的返回值 * @param {string} rel - 相对路径(仅用于日志定位) * @param {string} label - 场景名 * @returns {{removed:number,skipped:number,missing:number,failed:number}} 累计计数(快照) */ _noteDeleteResult(r, rel, label = 'removePublicFile') { if (!this.delStats) { this.delStats = { removed: 0, skipped: 0, missing: 0, failed: 0, lastErrorPath: null }; } const s = this.delStats; if (r === 'removed') s.removed += 1; else if (r === 'skipped') { s.skipped += 1; s.failed += 1; s.lastErrorPath = rel; } else if (r === 'missing') s.missing += 1; if (s.failed > 0) { const logger = (this.ctx && this.ctx.logger) || (this.app && this.app.logger); if (logger && logger.warn) { logger.warn( `[delStats] 累计删除异常 ${s.failed} 次` + `(removed=${s.removed} skipped=${s.skipped} missing=${s.missing})` + ` 最近失败路径=${s.lastErrorPath} ⇒ 疑似残留孤儿文件,` + ` 可用 scan_orphan_files.py 离线复核` ); } } return { ...s }; } /** 读取累计删除统计(健康检查 / 探针 / 门禁用;⛔ 不含任何副作用)。 */ getDeleteStats() { return { ...(this.delStats || { removed: 0, skipped: 0, missing: 0, failed: 0, lastErrorPath: null }) }; } removePublicFile(relativePath, label = 'removePublicFile') { const { ctx, app } = this; const logger = (ctx && ctx.logger) || app.logger; try { if (!relativePath || typeof relativePath !== 'string') return 'missing'; const norm = relativePath.replace(/\\/g, '/').replace(/^\/+/, ''); if (!/^(works|tmp)\//.test(norm) || norm.includes('..')) { logger.warn(`[removePublicFile:${label}] 拒绝删除非作品区路径(安全闸门): ${relativePath}`); return 'skipped'; } const absPath = path.join(app.baseDir, 'app/public', norm); return ctx.helper.safeCleanup(absPath, { label }); } catch (e) { logger.warn(`[removePublicFile:${label}] 异常(已忽略,不影响主流程): ${relativePath} -> ${e && e.message}`); return 'skipped'; } } } module.exports = UploadService;