Files
competition-website/app/service/upload.js
qubiaobiao 31da9580e5 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(兜底双向扫描,仓库级工具)
2026-09-29 15:06:53 +08:00

429 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const Service = require('egg').Service;
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
/**
* 上传服务 - UploadService
*
* 封装作品文件上传相关业务逻辑,包括截止时间校验、文件保存、
* 分片上传与合并等功能。适用于本科组和教师组的作品提交。
*/
class UploadService extends Service {
/**
* 获取用户的聚合报名信息
* @param {number} accountUid - 账号 UID
* @returns {Promise<Object|null>} 聚合报名记录
*/
async getUserAggregation(accountUid) {
const { ctx } = this;
return await ctx.model.Aggregation.findOne({
where: { account_uid: accountUid },
include: [
{
model: ctx.model.Team,
as: 'team',
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
},
],
});
}
/**
* 验证提交截止时间
* @param {string} group - 组别:benke / jiaoshi
* @returns {Promise<Object>} { valid, message, deadline }
*/
async validateDeadline(group) {
const { ctx } = this;
const timeConfig = await ctx.model.CompetitionTime.findOne({
where: { group },
});
// 权威截止时间 = end_time(管理后台「提交截止时间」输入框绑定的正是该列)。
// 历史遗留的 deadline 列仅作后备,避免出现「状态标签(按 end_time)显示进行中、
// 上传却按旧 deadline 判定已截止」的两条时间线不一致问题。
const effective = (timeConfig && (timeConfig.end_time || timeConfig.deadline)) || null;
if (!effective) {
return {
valid: false,
message: '提交截止时间尚未设置',
deadline: null,
};
}
const now = new Date();
const deadline = new Date(effective);
if (now > deadline) {
return {
valid: false,
message: '提交已截止',
deadline: deadline.toISOString(),
};
}
return {
valid: true,
message: '可以提交',
deadline: deadline.toISOString(),
};
}
/**
* 保存作品文件
* @param {ReadableStream} stream - 文件流
* @param {string} filename - 原始文件名
* @param {string} group - 组别
* @returns {Promise<Object>} { filePath, fileName, fileSize, md5 }
*/
async saveWorkFile(stream, filename, group) {
const { app } = this;
// 确保上传目录存在(无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT)
const uploadDir = path.join(app.baseDir, 'app/public/works', group);
try {
fs.mkdirSync(uploadDir, { recursive: true });
} catch (mkErr) {
this.app.logger.error('[saveWorkFile] mkdir failed', { uploadDir, message: mkErr.message });
throw mkErr;
}
// 构建安全文件名
const ext = path.extname(filename);
const timestamp = Date.now();
const safeFileName = `work_${timestamp}_${Math.random().toString(36).substring(2, 8)}${ext}`;
const filePath = path.join(uploadDir, safeFileName);
// 将文件流写入磁盘(用 pipe 自动处理背压,避免大文件内存暴涨 / 写入被截断)
const writeStream = fs.createWriteStream(filePath);
let fileSize = 0;
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
stream.on('data', chunk => { fileSize += chunk.length; });
writeStream.on('finish', resolve);
stream.on('error', reject);
writeStream.on('error', reject);
});
// 🔴 落盘完整性校验(F17-B 防「假成功」):
// 文件必须真实存在且字节数与流读数一致,否则视为上传失败,
// 绝不允许把「写了一半 / 没写出来的文件」记成成功——那会导致管理后台下载时 404。
const stat = fs.statSync(filePath);
if (stat.size !== fileSize) {
// 🔴 清理半截文件用 best-effort(同源缺陷族第三处):
// 裸 unlinkSync 一旦失败会抛出「删除失败」,把下面那条真正有诊断价值的
// 「文件写入不完整:期望 X 实际 Y」**顶掉**,排障时被引向错误方向。
// 清理成不成功都不影响结论——写入不完整这件事必须原样抛出。
this.ctx.helper.safeCleanup(filePath, { label: 'partialFile' });
throw new Error(`文件写入不完整:期望 ${fileSize} 字节,实际 ${stat.size} 字节`);
}
// 计算文件 MD5
const md5 = await this._calculateMd5(filePath);
// 构建相对路径(相对于 app/public/)
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
return {
filePath: relativePath,
fileName: filename,
fileSize,
md5,
};
}
/**
* 合并文件分片
* @param {Array} chunks - 分片文件路径数组(按索引排序)
* @param {string} targetPath - 目标文件路径
* @returns {Promise<Object>} { filePath, fileName, fileSize, md5 }
*/
async mergeFileChunks(chunks, targetPath) {
// 确保目标目录存在(无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT)
const targetDir = path.dirname(targetPath);
try {
fs.mkdirSync(targetDir, { recursive: true });
} catch (mkErr) {
this.app.logger.error('[mergeFileChunks] mkdir failed', { targetDir, message: mkErr.message });
throw mkErr;
}
// 按顺序合并所有分片。
// 🔴 并发要点(2026-08-10 修):这里过去用 fs.readFileSync 把整个分片同步读进内存,
// 再 writeStream.write(buf) 且不等 drain。后果有二:
// ① 同步读盘会「冻结 event loop」——一个 50MB 文件合并期间,同一 worker 上
// 其它考生的上传/答题请求全部排队,几十人同时交卷时表现为集体卡死;
// ② 不理会背压,N 个分片 Buffer 全堆在写队列里,单次合并瞬时吃掉整份文件的内存。
// 现改为逐分片流式 pipe({ end: false } 续写),全程异步 + 自动背压,
// 内存占用恒定在一个 highWaterMark(64KB),event loop 不再被阻塞。
//
// 🔴 监听器修复(2026-08-11):原实现在「循环内」对每个分片都执行
// `writeStream.on('error', reject)`,单一 writeStream 会被挂上 ≈分片数 个
// error 监听(10 分片 → 11 个),极端并发下触发 MaxListenersExceededWarning
// 并拖垮单 worker。现改为 writeStream.error 只挂【一次】,并用 settled 防重入。
let totalSize = 0;
await new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(targetPath);
let settled = false;
const fail = (err) => {
if (settled) return;
settled = true;
writeStream.destroy();
reject(err);
};
// 仅挂一次:writeStream 自身的错误
writeStream.on('error', fail);
(async () => {
try {
for (const chunkPath of chunks) {
await new Promise((res, rej) => {
const readStream = fs.createReadStream(chunkPath);
// 每个 readStream 是独立 emitter,挂一个 error 监听是合理的
readStream.on('error', rej);
readStream.on('data', buf => { totalSize += buf.length; });
readStream.on('end', res);
readStream.pipe(writeStream, { end: false });
});
// 删除已合并的分片文件(异步,避免同步 unlink 累积阻塞)
await fs.promises.unlink(chunkPath).catch(() => {});
}
writeStream.end();
writeStream.on('finish', () => {
if (settled) return;
settled = true;
resolve();
});
} catch (e) {
fail(e);
}
})();
}).catch((mergeErr) => {
// 清理半截目标文件,绝不把不完整文件记为成功(F17-B 防假成功)
return fs.promises.unlink(targetPath).catch(() => {}).then(() => { throw mergeErr; });
});
// 落盘完整性校验:合并后字节数必须与读入总量一致
const mergedStat = await fs.promises.stat(targetPath);
if (mergedStat.size !== totalSize) {
await fs.promises.unlink(targetPath).catch(() => {});
throw new Error(`分片合并不完整:期望 ${totalSize} 字节,实际 ${mergedStat.size} 字节`);
}
// 计算合并后文件的 MD5
const md5 = await this._calculateMd5(targetPath);
const fileName = path.basename(targetPath);
return {
filePath: targetPath,
fileName,
fileSize: totalSize,
md5,
};
}
/**
* 计算文件 MD5 值
* @param {string} filePath - 文件路径
* @returns {Promise<string>} MD5 哈希值
*/
async _calculateMd5(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* 保存作品提交记录到数据库
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @param {Object} fileInfo - 文件信息 { filePath, fileName, fileSize, md5 }
* @param {string} workName - 作品名称(可选)
* @returns {Promise<Object>} 保存的记录
*/
async saveSubmissionRecord(aggregationUid, group, fileInfo, workName = '', fileType = 'word') {
const { ctx } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// 按 (aggregation_uid, file_type) 定位记录:Word / PPT 各自独立存储,
// 避免后上传的文件覆盖先上传的文件(修复 PPT 顶掉 Word 的问题)。
const existing = await SubmissionModel.findOne({
where: { aggregation_uid: aggregationUid, file_type: fileType },
});
const payload = {
work_name: workName || fileInfo.fileName,
file_path: fileInfo.filePath,
file_name: fileInfo.fileName,
file_size: fileInfo.fileSize,
file_md5: fileInfo.md5,
upload_time: new Date(),
file_type: fileType,
};
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;
}
return await SubmissionModel.create({
aggregation_uid: aggregationUid,
...payload,
});
}
/**
* 查询用户的提交状态
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @returns {Promise<boolean>} 是否已提交
*/
async hasSubmitted(aggregationUid, group) {
const { ctx } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const count = await SubmissionModel.count({
where: { aggregation_uid: aggregationUid },
});
return count > 0;
}
/**
* 获取用户的提交历史列表
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @returns {Promise<Array>} 提交记录列表
*/
async getSubmissions(aggregationUid, group) {
const { ctx } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
return await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
order: [['upload_time', 'DESC']],
include: [
{
model: ctx.model.Aggregation,
as: 'aggregation',
attributes: ['aggregation_uid', 'team_uid', 'account_uid', 'identity'],
},
],
});
}
/**
* 删除用户的提交记录及其物理文件
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @param {number} id - 提交记录 ID
* @returns {Promise<boolean>} 删除成功返回 true,未找到(或无权限)返回 false
*/
async deleteSubmission(aggregationUid, group, id) {
const { ctx } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// 仅允许删除本人名下的记录(聚合键强约束,防越权)
const record = await SubmissionModel.findOne({
where: { id, aggregation_uid: aggregationUid },
});
if (!record) return false;
// 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx)
// 复用 removePublicFile:统一走「前缀白名单 + best-effort」的安全删除路径,
// 避免各处各写一份裸 unlink(历史上正是这种分散写法导致清理失败被静默吞掉)。
if (record.file_path) {
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;