From 31da9580e5d45bf01d5d1c701e01edbce59aef45 Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Tue, 29 Sep 2026 15:06:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=E6=B2=BB=E7=90=86=E5=AD=A4?= =?UTF-8?q?=E5=84=BF=E6=96=87=E4=BB=B6=E4=B8=89=E6=A0=B9=E5=9B=A0=EF=BC=88?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E5=89=8D=E5=88=A0=E6=97=A7=E4=BB=B6=20/=20?= =?UTF-8?q?=E5=85=A5=E5=BA=93=E5=A4=B1=E8=B4=A5=E5=9B=9E=E6=BB=9A=20/=20?= =?UTF-8?q?=E6=B8=85=E5=BA=93=E5=90=8C=E6=AD=A5=E5=88=A0=E4=BB=B6=EF=BC=89?= =?UTF-8?q?=EF=BC=88BACKLOG=20C9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R45:C8 只清了存量(1003 个 / 9.62 GB),「孤儿为何持续产生」的三条根因仍在,本次一并治理。 顺序铁律「先库后盘」:文件删除不可回滚,若先删盘而 DB 写/事务失败 ⇒ DB 指向已删文件 ⇒ 下载 404 = 数据丢失(比留一个孤儿严重)。故全部删除点一律「DB 侧确定成功 → 才删物理文件」。 - app/service/upload.js · saveSubmissionRecord:覆盖前记住旧物理文件路径,existing.update() 成功之后才删旧件 (原先 upsert 只改 DB 指向 ⇒ 重传 N 次 = 1 条 DB + N-1 个孤儿) · deleteSubmission:裸 fs.unlinkSync → 复用统一入口 · 新增 removePublicFile(relativePath, label):唯一删除入口。仅允许 works/ 或 tmp/ 前缀 (挡住脏 file_path 指向 templates/、carousel/ 等静态资源被连带删除)+ 拒含 `..` + best-effort 永不抛错,返回 removed|missing|skipped(复用 helper.safeCleanup) - app/controller/upload.js · upload() / mergeChunks():新增 savedFileInfo|mergedRelPath + recordPersisted 两个标志, catch 里仅当「已落盘 && 未入库」才回滚删除 ⇒ 已入库还删会删掉正被 DB 引用的文件 - app/controller/admin.js · resetTestData:事务内先只读收集六列路径(exam/work/zhongzhi/gaozhi/benke/jiaoshi), tx.commit() 之后才删物理文件 + 去重 + 上报 orphanFileCandidates/orphanFilesRemoved 验证:verify-c9.mjs 24/24 · verify-c9-reset.mjs 12/12 · verify-c9-rollback.mjs 13/13 check_upload_import_gate.py 14 PASS/0 FAIL · 后端重启(node dev.js)后真实接口联调通过 配套:scripts/scan_orphan_files.py(兜底双向扫描,仓库级工具) --- app/controller/admin.js | 49 ++++++++++++++++++++++++++ app/controller/upload.js | 26 ++++++++++++++ app/service/upload.js | 74 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 142 insertions(+), 7 deletions(-) diff --git a/app/controller/admin.js b/app/controller/admin.js index a7edc6e..f8b3e72 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -1591,6 +1591,27 @@ class AdminController extends Controller { let gaozhiDeleted = 0; let benkeDeleted = 0; let jiaoshiDeleted = 0; + // 🔴 C9 根因③ 修复(2026-09-29 · R45):清库只删行、不删物理文件 ⇒ 磁盘孤儿随时间累积 + // (实证:`app/public/works` 已积到 1003 个 / 9.62 GB,另 `tmp` 6.6 GB)。 + // 这里先在本事务内把待删记录引用的文件路径**收集起来**(只读), + // 待事务**提交成功之后**才真正删文件。 + // ⚠️ 为什么不能直接在事务里删:文件删除**不可回滚**。一旦事务回滚, + // DB 记录还在、文件却已消失 ⇒ 下载 404(数据丢失,比留一个孤儿严重得多)。 + // 先收集后删、且只删「行确已删除」的那批,是唯一安全的顺序。 + const staleFilePaths = []; + // 只读收集器:按「模型 + 文件列 + 条件」把非空路径收集进 staleFilePaths + const collectPaths = async (Model, column, where) => { + const rows = await Model.findAll({ + where, + attributes: [ column ], + raw: true, + transaction: tx, + }); + for (const r of rows) { + const p = r[column]; + if (p && String(p).trim()) staleFilePaths.push(String(p).trim()); + } + }; try { // 1. 重置选手状态(仅测试数据选手):恢复为「未激活 + 默认口令 + 无成绩」, // 便于从头回归「短信激活」与完整参赛流程;同时清空成绩,避免成绩管理残留旧分。 @@ -1616,6 +1637,14 @@ class AdminController extends Controller { // (与本次重置同处一个事务:若后续任一步失败,发布态复位一并回滚,绝不泄漏。) await service.admin.publishScores(false, undefined, tx); + // 1.2 收集「即将被删除」的记录所引用的物理文件路径(必须在 DESTROY 之前,否则路径已随行消失)。 + // 列名以 model 定义为准:exam/zhongzhi/gaozhi 用 `word_file_path`, + // work/benke/jiaoshi 用 `file_path`(不可跨表套用,见 verify_* 门禁的同类教训)。 + await collectPaths(ctx.model.ExamSubmission, 'word_file_path', { contestant_id: testIds }); + await collectPaths(ctx.model.WorkSubmission, 'file_path', { contestant_id: testIds }); + await collectPaths(ctx.model.ZhongzhiSubmission, 'word_file_path', { aggregation_uid: testIds }); + await collectPaths(ctx.model.GaozhiSubmission, 'word_file_path', { aggregation_uid: testIds }); + // 2. 删除 exam_submissions(仅测试数据选手;客观题答案内嵌其中,一并清除) examDeleted = await ctx.model.ExamSubmission.destroy({ where: { contestant_id: testIds }, @@ -1652,6 +1681,12 @@ class AdminController extends Controller { }); const testAggUids = testAggRows.map(a => a.aggregation_uid); + // 1.3 补收集本科/教师组作品路径(键为聚合 uid,须在上面的 destroy 之前收集) + if (testAggUids.length) { + await collectPaths(ctx.model.BenkeSubmission, 'file_path', { aggregation_uid: testAggUids }); + await collectPaths(ctx.model.JiaoshiSubmission, 'file_path', { aggregation_uid: testAggUids }); + } + benkeDeleted = testAggUids.length ? await ctx.model.BenkeSubmission.destroy({ where: { aggregation_uid: testAggUids }, transaction: tx, @@ -1668,6 +1703,20 @@ class AdminController extends Controller { throw txErr; } + // 1.4 事务已成功提交 ⇒ 行确已删除,此时才删物理文件(C9 根因③)。 + // 顺序仍是「先库后盘」:若在提交前删文件而事务回滚,会留下「记录在、文件没了」的 404。 + // 删文件用 service.upload.removePublicFile —— 统一走「只有 works/、tmp/ 前缀才允许删 + // + best-effort 不抛错」的安全路径;失败的文件不会静默消失,会被计数上报, + // 也可由兜底扫描 scripts/scan_orphan_files.py 捞出。 + const uniqueStalePaths = [ ...new Set(staleFilePaths) ]; + let orphanFilesRemoved = 0; + for (const p of uniqueStalePaths) { + if (service.upload.removePublicFile(p, 'resetTestData') === 'removed') orphanFilesRemoved++; + } + + results.orphanFileCandidates = uniqueStalePaths.length; + results.orphanFilesRemoved = orphanFilesRemoved; + results.contestantsReset = contestantCount; results.scorePublishReset = true; // 已在本事务内调用 publishScores(false),发布态已复位 results.examSubmissionsDeleted = examDeleted; diff --git a/app/controller/upload.js b/app/controller/upload.js index 1599b88..8df7d9b 100644 --- a/app/controller/upload.js +++ b/app/controller/upload.js @@ -54,6 +54,11 @@ class UploadController extends Controller { 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; @@ -129,6 +134,7 @@ class UploadController extends Controller { // 保存文件 const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group); + savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用 // 获取作品名称(流式模式字段在 stream.fields 中) const workName = (stream.fields && stream.fields.work_name) || stream.filename; @@ -143,6 +149,8 @@ class UploadController extends Controller { workName, fileType ); + // 🔴 入库成功即置位:此后任何异常都**不得**再删这个文件(它已被 DB 引用) + recordPersisted = true; ctx.body = ctx.helper.success({ id: record.id, @@ -155,6 +163,14 @@ class UploadController extends Controller { 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); @@ -247,6 +263,9 @@ class UploadController extends Controller { */ async mergeChunks() { const { ctx, service, app } = this; + // 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。 + let mergedRelPath = null; + let recordPersisted = false; try { const { group } = ctx.params; @@ -324,6 +343,7 @@ class UploadController extends Controller { // 构建相对路径 const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/'); + mergedRelPath = relativePath; // 供 catch 回滚孤儿文件用 // 保存提交记录 const fileInfo = { @@ -339,6 +359,8 @@ class UploadController extends Controller { work_name || fileName, file_type || 'word' ); + // 入库成功即置位:此后异常不得再删该文件(已被 DB 引用) + recordPersisted = true; ctx.body = ctx.helper.success({ id: record.id, @@ -354,6 +376,10 @@ class UploadController extends Controller { releaseFileIo(); } } catch (err) { + // 🔴 孤儿回滚(C9 · R45):合并结果已落盘但记录未入库 ⇒ 清掉该文件。语义同 upload()。 + if (mergedRelPath && !recordPersisted) { + service.upload.removePublicFile(mergedRelPath, 'merge-rollback'); + } ctx.logger.error('分片合并失败:', err); ctx.body = ctx.helper.error(500, '分片合并失败'); } diff --git a/app/service/upload.js b/app/service/upload.js index 3d3be0a..9492c5b 100644 --- a/app/service/upload.js +++ b/app/service/upload.js @@ -275,10 +275,31 @@ class UploadService extends Service { }; 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'); + if (r !== 'removed') { + this.app.logger.warn( + `[saveSubmissionRecord] 旧文件未能清理,已成为孤儿(等待兜底扫描 script/scan_orphan_files.py): ${stalePath} -> ${r}` + ); + } + } return existing; } @@ -339,7 +360,7 @@ class UploadService extends Service { * @returns {Promise} 删除成功返回 true,未找到(或无权限)返回 false */ async deleteSubmission(aggregationUid, group, id) { - const { ctx, app } = this; + const { ctx } = this; const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission; @@ -351,18 +372,57 @@ class UploadService extends Service { if (!record) return false; // 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx) + // 复用 removePublicFile:统一走「前缀白名单 + best-effort」的安全删除路径, + // 避免各处各写一份裸 unlink(历史上正是这种分散写法导致清理失败被静默吞掉)。 if (record.file_path) { - const absPath = path.join(app.baseDir, 'app/public', record.file_path); - try { - if (fs.existsSync(absPath)) fs.unlinkSync(absPath); - } catch (e) { - ctx.logger.error('[deleteSubmission] 删除物理文件失败:', e.message); - } + this.removePublicFile(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'} 删除结果(不抛错) + */ + 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;