fix(backend): 治理孤儿文件三根因(覆盖前删旧件 / 入库失败回滚 / 清库同步删件)(BACKLOG C9)

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(兜底双向扫描,仓库级工具)
This commit is contained in:
2026-09-29 15:06:53 +08:00
parent e8a5b34d42
commit 31da9580e5
3 changed files with 142 additions and 7 deletions

View File

@@ -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;