Files
competition-website/app/controller/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

538 lines
20 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 Controller = require('egg').Controller;
const path = require('path');
const fs = require('fs');
const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard');
/**
* 上传控制器 - UploadController
*
* 负责作品文件上传相关功能,包括普通上传、分片上传、分片查询、
* 合并分片、提交状态查询等。适用于本科组和教师组。
*
* 主要接口:
* - POST /api/upload/:group : 上传作品文件
* - POST /api/upload/:group/chunk : 上传分片
* - POST /api/upload/:group/merge : 合并分片
* - GET /api/upload/:group/chunk-status : 查询分片状态
* - GET /api/upload/:group/status : 查询提交状态
* - GET /api/upload/:group/submissions : 查询提交历史
*/
class UploadController extends Controller {
/**
* 校验组别参数
* @param {string} group - 组别
* @returns {boolean} 是否有效
*/
_validateGroup(group) {
const validGroups = ['benke', 'jiaoshi'];
if (!validGroups.includes(group)) {
this.ctx.body = this.ctx.helper.error(400, '无效的组别,仅支持 benke / jiaoshi');
return false;
}
return true;
}
/**
* 获取用户的聚合报名信息
* @returns {Promise<Object|null>}
*/
async _getUserAggregation() {
const user = this.ctx.state.currentUser;
if (!user || !user.uid) return null;
return await this.service.upload.getUserAggregation(user.uid);
}
/**
* 上传作品文件
* POST /api/upload/:group
*
* 支持 .pdf / .doc / .docx / .ppt / .pptx / .zip 格式,最大 50MB
*/
async upload() {
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;
if (!this._validateGroup(group)) return;
// 🔴 文件 IO 并发护栏(2026-09-29 · R43 补齐 —— 本端点此前**零护栏**)
//
// 背景:本端点是**在用的**作品上传路径(前端 Competition.vue:477 逐文件串行调用),
// 而同文件的 uploadChunk(:156) / mergeChunks(:275) / exam.uploadWord(:503) 都调了本护栏,
// 唯独真正承载考生上传的这一条**既未调 file_io_guard、也不在 concurrency_guard
// 的 HEAVY_PATTERNS 内**(R42 发现,已同步补入中间件)。两处都补 = 闸门 + 护栏双保险。
//
// 位置:与 uploadChunk 同构 —— 在**读取请求体之前**(ctx.getFileStream() 在下方)取配额,
// 提供连接级背压:极端并发下超限即回 429,请求体不会被解析/缓冲,避免 fd/事件循环雪崩。
//
// ⚠️ 生产缓解事实(诚实标注,避免高估本改动):服务器入方向带宽仅 ≈1.25 MB/s,
// 收包速率被物理封顶、并发上传大多耗在「等字节」⇒ **当前实际风险有限**;
// 但一旦按 C3 提升入带宽,收包不再被封顶,护栏即从「保险」变为「必需」
// (故 C7 与 C3 绑定:先补护栏再升带宽)。
try {
await acquireFileIo();
ioAcquired = true;
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
return;
}
// 验证截止时间
const deadlineCheck = await service.upload.validateDeadline(group);
if (!deadlineCheck.valid) {
ctx.body = ctx.helper.error(6001, deadlineCheck.message);
return;
}
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 验证组别匹配
if (aggregation.team && aggregation.team.group_name !== group) {
ctx.body = ctx.helper.error(403, '您不属于该组别');
return;
}
// 获取上传的文件流
const stream = await ctx.getFileStream();
if (!stream) {
ctx.body = ctx.helper.error(400, '未接收到文件');
return;
}
// 验证文件扩展名
const allowedExts = ['.pdf', '.doc', '.docx', '.ppt', '.pptx', '.zip'];
const ext = path.extname(stream.filename).toLowerCase();
if (!allowedExts.includes(ext)) {
ctx.body = ctx.helper.error(6002, '不支持的文件格式,仅支持 pdf/doc/docx/ppt/pptx/zip');
return;
}
// 验证文件大小:zip 最大 200MB,其余格式最大 100MB(从请求头判断)
const contentLength = parseInt(ctx.get('content-length')) || 0;
const isZip = ext === '.zip';
const maxSize = isZip ? 200 * 1024 * 1024 : 100 * 1024 * 1024;
const limitLabel = isZip ? '200MB' : '100MB';
if (contentLength > maxSize) {
ctx.body = ctx.helper.error(6003, `文件大小超过限制(最大 ${limitLabel})`);
return;
}
// 保存文件
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;
// 文件类型:word / ppt(前端上传时透传)
const fileType = (stream.fields && stream.fields.file_type) || 'word';
// 保存提交记录
const record = await service.upload.saveSubmissionRecord(
aggregation.aggregation_uid,
group,
fileInfo,
workName,
fileType
);
// 🔴 入库成功即置位:此后任何异常都**不得**再删这个文件(它已被 DB 引用)
recordPersisted = true;
ctx.body = ctx.helper.success({
id: record.id,
workName: record.work_name,
filePath: fileInfo.filePath,
fileUrl: ctx.helper.fileUrl(fileInfo.filePath),
fileName: fileInfo.fileName,
fileSize: fileInfo.fileSize,
md5: fileInfo.md5,
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);
} finally {
// 🔴 释放配额。为何用 `if (ioAcquired)` 而非像 uploadChunk 那样无条件 release:
// 本方法的外层 try 包住了「校验 + 取配额 + 落盘」全过程,而 429 分支是
// **acquire 失败**(配额没拿到)后 return 的 —— 若无条件释放,会把**别人的**配额
// 放掉,配额计数下溢 ⇒ 护栏静默失效(真实并发上限被虚高),是最难查的一类退化。
// ⚠️ 反之也不能省 last 这个 finally:中途任何 return 都不允许泄漏配额,
// 否则配额只减不增,若干轮后上传通道**永久 429**。
if (ioAcquired) releaseFileIo();
}
}
/**
* 上传分片
* POST /api/upload/:group/chunk
*
* Body: multipart/form-data 包含 chunk(文件分片)、chunkIndex、totalChunks、fileId、fileName
*/
async uploadChunk() {
const { ctx, app } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 🔴 文件 IO 并发护栏:先于读取请求体获取配额,提供连接级背压;
// 极端并发(无带宽限制 + 单 worker)下超出上限则返回 429,
// 由前端 uploadWithRetry 指数退避重试,避免 fd/事件循环被压垮雪崩。
try {
await acquireFileIo();
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
return;
}
try {
// 获取上传的文件流(流式模式下,非文件字段需在消费流后从 ctx.request.body 读取)
const stream = await ctx.getFileStream();
if (!stream) {
ctx.body = ctx.helper.error(400, '未接收到分片数据');
return;
}
// 流式模式下,非文件字段在 stream.fields 中(非 ctx.request.body)
const { chunkIndex, totalChunks, fileId, fileName } = stream.fields || {};
if (chunkIndex === undefined || !totalChunks || !fileId || !fileName) {
ctx.body = ctx.helper.error(400, '缺少必要参数:chunkIndex, totalChunks, fileId, fileName');
return;
}
// 构建临时分片目录
const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId);
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
// 保存分片文件
const chunkFilePath = path.join(tempDir, `chunk_${String(chunkIndex).padStart(6, '0')}`);
const writeStream = fs.createWriteStream(chunkFilePath);
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('error', reject);
stream.on('error', reject);
});
ctx.body = ctx.helper.success({
received: parseInt(chunkIndex),
totalChunks: parseInt(totalChunks),
fileId,
}, `分片 ${chunkIndex} 上传成功`);
} finally {
releaseFileIo();
}
} catch (err) {
ctx.logger.error('分片上传失败:', err);
ctx.body = ctx.helper.error(500, '分片上传失败');
}
}
/**
* 合并分片
* POST /api/upload/:group/merge
*
* Body: { fileId, fileName, totalChunks, work_name }
*/
async mergeChunks() {
const { ctx, service, app } = this;
// 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。
let mergedRelPath = null;
let recordPersisted = false;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 验证截止时间
const deadlineCheck = await service.upload.validateDeadline(group);
if (!deadlineCheck.valid) {
ctx.body = ctx.helper.error(6001, deadlineCheck.message);
return;
}
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
const { fileId, fileName, totalChunks, work_name, file_type } = ctx.request.body;
if (!fileId || !fileName) {
ctx.body = ctx.helper.error(400, '缺少必要参数:fileId, fileName');
return;
}
// 检查分片目录
const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId);
if (!fs.existsSync(tempDir)) {
ctx.body = ctx.helper.error(6004, '未找到分片文件');
return;
}
// 收集所有分片文件并按文件名排序
const chunkFiles = fs.readdirSync(tempDir)
.filter(f => f.startsWith('chunk_'))
.sort()
.map(f => path.join(tempDir, f));
const expectedCount = parseInt(totalChunks) || 0;
if (expectedCount > 0 && chunkFiles.length < expectedCount) {
ctx.body = ctx.helper.error(6005, `分片未完成,已收到 ${chunkFiles.length}/${expectedCount}`);
return;
}
// 构建目标文件路径
const uploadDir = path.join(app.baseDir, 'app/public/works', group);
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const ext = path.extname(fileName);
const timestamp = Date.now();
const safeFileName = `work_${timestamp}_${Math.random().toString(36).substring(2, 8)}${ext}`;
const targetPath = path.join(uploadDir, safeFileName);
// 🔴 文件 IO 并发护栏:分片合并是重 IO,与 uploadChunk 共享同一配额,
// 极端并发(无带宽限制 + 单 worker)下被统一限流,避免 fd/事件循环耗尽雪崩。
try {
await acquireFileIo();
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
return;
}
try {
// 合并分片
const mergeResult = await service.upload.mergeFileChunks(chunkFiles, targetPath);
// 🔴 删除临时目录:best-effort,**绝不能让清理失败把已成功的合并判成 500**。
// 旧代码是裸 fs.rmdirSync,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会
// 抛到外层 catch → 返回「分片合并失败」,而目标文件已落盘且 DB 记录还没写,
// 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。
ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' });
// 构建相对路径
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
mergedRelPath = relativePath; // 供 catch 回滚孤儿文件用
// 保存提交记录
const fileInfo = {
filePath: relativePath,
fileName,
fileSize: mergeResult.fileSize,
md5: mergeResult.md5,
};
const record = await service.upload.saveSubmissionRecord(
aggregation.aggregation_uid,
group,
fileInfo,
work_name || fileName,
file_type || 'word'
);
// 入库成功即置位:此后异常不得再删该文件(已被 DB 引用)
recordPersisted = true;
ctx.body = ctx.helper.success({
id: record.id,
workName: record.work_name,
filePath: relativePath,
fileUrl: ctx.helper.fileUrl(relativePath),
fileName,
fileSize: mergeResult.fileSize,
md5: mergeResult.md5,
uploadTime: record.upload_time || new Date().toISOString(),
}, '分片合并成功');
} finally {
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, '分片合并失败');
}
}
/**
* 查询分片上传进度
* GET /api/upload/:group/chunk-status?fileId=xxx
*/
async chunkStatus() {
const { ctx, app } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const { fileId } = ctx.query;
if (!fileId) {
ctx.body = ctx.helper.error(400, '缺少 fileId 参数');
return;
}
// 检查临时目录
const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId);
if (!fs.existsSync(tempDir)) {
ctx.body = ctx.helper.success({
received: 0,
chunks: [],
}, '尚未收到分片');
return;
}
const chunkFiles = fs.readdirSync(tempDir)
.filter(f => f.startsWith('chunk_'))
.sort();
ctx.body = ctx.helper.success({
received: chunkFiles.length,
chunks: chunkFiles.map(f => f.replace('chunk_', '')),
}, '获取分片状态成功');
} catch (err) {
ctx.logger.error('查询分片状态失败:', err);
ctx.body = ctx.helper.error(500, '查询分片状态失败');
}
}
/**
* 查询当前用户的提交状态
* GET /api/upload/:group/status
*/
async status() {
const { ctx, service } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 检查提交状态
const submitted = await service.upload.hasSubmitted(aggregation.aggregation_uid, group);
// 检查截止时间
const deadlineCheck = await service.upload.validateDeadline(group);
ctx.body = ctx.helper.success({
submitted,
deadline: deadlineCheck.deadline,
canSubmit: deadlineCheck.valid,
}, '获取提交状态成功');
} catch (err) {
ctx.logger.error('查询提交状态失败:', err);
ctx.body = ctx.helper.error(500, '查询提交状态失败');
}
}
/**
* 删除已上传的作品文件
* DELETE /api/upload/:group/:id
*
* 仅允许删除当前登录用户本人的提交记录,并同步删除物理文件。
*/
async delete() {
const { ctx, service } = this;
try {
const { group, id } = ctx.params;
if (!this._validateGroup(group)) return;
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
const numId = parseInt(id, 10);
if (!Number.isInteger(numId)) {
ctx.body = ctx.helper.error(400, '无效的文件 ID');
return;
}
const ok = await service.upload.deleteSubmission(aggregation.aggregation_uid, group, numId);
if (!ok) {
ctx.body = ctx.helper.error(404, '未找到该文件或无权删除');
return;
}
ctx.body = ctx.helper.success({ id: numId }, '文件已删除');
} catch (err) {
ctx.logger.error('删除提交文件失败:', err);
ctx.body = ctx.helper.error(500, '删除失败');
}
}
/**
* 查询当前用户的提交历史
* GET /api/upload/:group/submissions
*/
async submissions() {
const { ctx, service } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 获取提交历史
const submissionList = await service.upload.getSubmissions(
aggregation.aggregation_uid,
group
);
ctx.body = ctx.helper.success({
list: submissionList,
total: submissionList.length,
}, '获取提交历史成功');
} catch (err) {
ctx.logger.error('查询提交历史失败:', err);
ctx.body = ctx.helper.error(500, '查询提交历史失败');
}
}
}
module.exports = UploadController;