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

@@ -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<boolean>} 删除成功返回 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/<group>/<file>`;前缀白名单可挡住
* 「脏数据 / 手工改库 / 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;