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

@@ -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, '分片合并失败');
}