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:
@@ -1591,6 +1591,27 @@ class AdminController extends Controller {
|
|||||||
let gaozhiDeleted = 0;
|
let gaozhiDeleted = 0;
|
||||||
let benkeDeleted = 0;
|
let benkeDeleted = 0;
|
||||||
let jiaoshiDeleted = 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 {
|
try {
|
||||||
// 1. 重置选手状态(仅测试数据选手):恢复为「未激活 + 默认口令 + 无成绩」,
|
// 1. 重置选手状态(仅测试数据选手):恢复为「未激活 + 默认口令 + 无成绩」,
|
||||||
// 便于从头回归「短信激活」与完整参赛流程;同时清空成绩,避免成绩管理残留旧分。
|
// 便于从头回归「短信激活」与完整参赛流程;同时清空成绩,避免成绩管理残留旧分。
|
||||||
@@ -1616,6 +1637,14 @@ class AdminController extends Controller {
|
|||||||
// (与本次重置同处一个事务:若后续任一步失败,发布态复位一并回滚,绝不泄漏。)
|
// (与本次重置同处一个事务:若后续任一步失败,发布态复位一并回滚,绝不泄漏。)
|
||||||
await service.admin.publishScores(false, undefined, tx);
|
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(仅测试数据选手;客观题答案内嵌其中,一并清除)
|
// 2. 删除 exam_submissions(仅测试数据选手;客观题答案内嵌其中,一并清除)
|
||||||
examDeleted = await ctx.model.ExamSubmission.destroy({
|
examDeleted = await ctx.model.ExamSubmission.destroy({
|
||||||
where: { contestant_id: testIds },
|
where: { contestant_id: testIds },
|
||||||
@@ -1652,6 +1681,12 @@ class AdminController extends Controller {
|
|||||||
});
|
});
|
||||||
const testAggUids = testAggRows.map(a => a.aggregation_uid);
|
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({
|
benkeDeleted = testAggUids.length ? await ctx.model.BenkeSubmission.destroy({
|
||||||
where: { aggregation_uid: testAggUids },
|
where: { aggregation_uid: testAggUids },
|
||||||
transaction: tx,
|
transaction: tx,
|
||||||
@@ -1668,6 +1703,20 @@ class AdminController extends Controller {
|
|||||||
throw txErr;
|
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.contestantsReset = contestantCount;
|
||||||
results.scorePublishReset = true; // 已在本事务内调用 publishScores(false),发布态已复位
|
results.scorePublishReset = true; // 已在本事务内调用 publishScores(false),发布态已复位
|
||||||
results.examSubmissionsDeleted = examDeleted;
|
results.examSubmissionsDeleted = examDeleted;
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ class UploadController extends Controller {
|
|||||||
const { ctx, service } = this;
|
const { ctx, service } = this;
|
||||||
// 🔴 配额获取标志(见方法末 finally 的说明:为何不照搬 uploadChunk 的无条件 release)
|
// 🔴 配额获取标志(见方法末 finally 的说明:为何不照搬 uploadChunk 的无条件 release)
|
||||||
let ioAcquired = false;
|
let ioAcquired = false;
|
||||||
|
// 🔴 C9 根因①的孪生缺陷(2026-09-29 · R45):文件已落盘、但 DB 记录没写成 ⇒ 同样是孤儿。
|
||||||
|
// 两个标志分别表示「已落盘」「已入库」,只有「落盘但没入库」才允许回滚删除 ——
|
||||||
|
// 若已经入库还去删文件,就会删掉**正在被引用**的文件(下载 404),那是数据丢失。
|
||||||
|
let savedFileInfo = null;
|
||||||
|
let recordPersisted = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { group } = ctx.params;
|
const { group } = ctx.params;
|
||||||
@@ -129,6 +134,7 @@ class UploadController extends Controller {
|
|||||||
|
|
||||||
// 保存文件
|
// 保存文件
|
||||||
const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group);
|
const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group);
|
||||||
|
savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用
|
||||||
|
|
||||||
// 获取作品名称(流式模式字段在 stream.fields 中)
|
// 获取作品名称(流式模式字段在 stream.fields 中)
|
||||||
const workName = (stream.fields && stream.fields.work_name) || stream.filename;
|
const workName = (stream.fields && stream.fields.work_name) || stream.filename;
|
||||||
@@ -143,6 +149,8 @@ class UploadController extends Controller {
|
|||||||
workName,
|
workName,
|
||||||
fileType
|
fileType
|
||||||
);
|
);
|
||||||
|
// 🔴 入库成功即置位:此后任何异常都**不得**再删这个文件(它已被 DB 引用)
|
||||||
|
recordPersisted = true;
|
||||||
|
|
||||||
ctx.body = ctx.helper.success({
|
ctx.body = ctx.helper.success({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
@@ -155,6 +163,14 @@ class UploadController extends Controller {
|
|||||||
uploadTime: record.upload_time || new Date().toISOString(),
|
uploadTime: record.upload_time || new Date().toISOString(),
|
||||||
}, '文件上传成功');
|
}, '文件上传成功');
|
||||||
} catch (err) {
|
} 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);
|
ctx.logger.error('文件上传失败:', err);
|
||||||
const msg = (err && err.message) ? err.message : '未知错误';
|
const msg = (err && err.message) ? err.message : '未知错误';
|
||||||
ctx.body = ctx.helper.error(500, msg);
|
ctx.body = ctx.helper.error(500, msg);
|
||||||
@@ -247,6 +263,9 @@ class UploadController extends Controller {
|
|||||||
*/
|
*/
|
||||||
async mergeChunks() {
|
async mergeChunks() {
|
||||||
const { ctx, service, app } = this;
|
const { ctx, service, app } = this;
|
||||||
|
// 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。
|
||||||
|
let mergedRelPath = null;
|
||||||
|
let recordPersisted = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { group } = ctx.params;
|
const { group } = ctx.params;
|
||||||
@@ -324,6 +343,7 @@ class UploadController extends Controller {
|
|||||||
|
|
||||||
// 构建相对路径
|
// 构建相对路径
|
||||||
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
|
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
|
||||||
|
mergedRelPath = relativePath; // 供 catch 回滚孤儿文件用
|
||||||
|
|
||||||
// 保存提交记录
|
// 保存提交记录
|
||||||
const fileInfo = {
|
const fileInfo = {
|
||||||
@@ -339,6 +359,8 @@ class UploadController extends Controller {
|
|||||||
work_name || fileName,
|
work_name || fileName,
|
||||||
file_type || 'word'
|
file_type || 'word'
|
||||||
);
|
);
|
||||||
|
// 入库成功即置位:此后异常不得再删该文件(已被 DB 引用)
|
||||||
|
recordPersisted = true;
|
||||||
|
|
||||||
ctx.body = ctx.helper.success({
|
ctx.body = ctx.helper.success({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
@@ -354,6 +376,10 @@ class UploadController extends Controller {
|
|||||||
releaseFileIo();
|
releaseFileIo();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 🔴 孤儿回滚(C9 · R45):合并结果已落盘但记录未入库 ⇒ 清掉该文件。语义同 upload()。
|
||||||
|
if (mergedRelPath && !recordPersisted) {
|
||||||
|
service.upload.removePublicFile(mergedRelPath, 'merge-rollback');
|
||||||
|
}
|
||||||
ctx.logger.error('分片合并失败:', err);
|
ctx.logger.error('分片合并失败:', err);
|
||||||
ctx.body = ctx.helper.error(500, '分片合并失败');
|
ctx.body = ctx.helper.error(500, '分片合并失败');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,10 +275,31 @@ class UploadService extends Service {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (existing) {
|
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({
|
await existing.update({
|
||||||
...payload,
|
...payload,
|
||||||
score: null, // 重新上传后清除评分
|
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;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +360,7 @@ class UploadService extends Service {
|
|||||||
* @returns {Promise<boolean>} 删除成功返回 true,未找到(或无权限)返回 false
|
* @returns {Promise<boolean>} 删除成功返回 true,未找到(或无权限)返回 false
|
||||||
*/
|
*/
|
||||||
async deleteSubmission(aggregationUid, group, id) {
|
async deleteSubmission(aggregationUid, group, id) {
|
||||||
const { ctx, app } = this;
|
const { ctx } = this;
|
||||||
const SubmissionModel = group === 'benke'
|
const SubmissionModel = group === 'benke'
|
||||||
? ctx.model.BenkeSubmission
|
? ctx.model.BenkeSubmission
|
||||||
: ctx.model.JiaoshiSubmission;
|
: ctx.model.JiaoshiSubmission;
|
||||||
@@ -351,18 +372,57 @@ class UploadService extends Service {
|
|||||||
if (!record) return false;
|
if (!record) return false;
|
||||||
|
|
||||||
// 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx)
|
// 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx)
|
||||||
|
// 复用 removePublicFile:统一走「前缀白名单 + best-effort」的安全删除路径,
|
||||||
|
// 避免各处各写一份裸 unlink(历史上正是这种分散写法导致清理失败被静默吞掉)。
|
||||||
if (record.file_path) {
|
if (record.file_path) {
|
||||||
const absPath = path.join(app.baseDir, 'app/public', record.file_path);
|
this.removePublicFile(record.file_path, 'deleteSubmission');
|
||||||
try {
|
|
||||||
if (fs.existsSync(absPath)) fs.unlinkSync(absPath);
|
|
||||||
} catch (e) {
|
|
||||||
ctx.logger.error('[deleteSubmission] 删除物理文件失败:', e.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await record.destroy();
|
await record.destroy();
|
||||||
return true;
|
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;
|
module.exports = UploadService;
|
||||||
|
|||||||
Reference in New Issue
Block a user