2026-08-17 12:25:42 +08:00
|
|
|
|
'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;
|
2026-09-29 13:17:53 +08:00
|
|
|
|
// 🔴 配额获取标志(见方法末 finally 的说明:为何不照搬 uploadChunk 的无条件 release)
|
|
|
|
|
|
let ioAcquired = false;
|
2026-09-29 15:06:53 +08:00
|
|
|
|
// 🔴 C9 根因①的孪生缺陷(2026-09-29 · R45):文件已落盘、但 DB 记录没写成 ⇒ 同样是孤儿。
|
|
|
|
|
|
// 两个标志分别表示「已落盘」「已入库」,只有「落盘但没入库」才允许回滚删除 ——
|
|
|
|
|
|
// 若已经入库还去删文件,就会删掉**正在被引用**的文件(下载 404),那是数据丢失。
|
|
|
|
|
|
let savedFileInfo = null;
|
|
|
|
|
|
let recordPersisted = false;
|
2026-08-17 12:25:42 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { group } = ctx.params;
|
|
|
|
|
|
if (!this._validateGroup(group)) return;
|
|
|
|
|
|
|
2026-09-29 13:17:53 +08:00
|
|
|
|
// 🔴 文件 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 12:25:42 +08:00
|
|
|
|
// 验证截止时间
|
|
|
|
|
|
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);
|
2026-09-29 15:06:53 +08:00
|
|
|
|
savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用
|
2026-08-17 12:25:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 获取作品名称(流式模式字段在 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
|
|
|
|
|
|
);
|
2026-09-29 15:06:53 +08:00
|
|
|
|
// 🔴 入库成功即置位:此后任何异常都**不得**再删这个文件(它已被 DB 引用)
|
|
|
|
|
|
recordPersisted = true;
|
2026-08-17 12:25:42 +08:00
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-09-29 15:06:53 +08:00
|
|
|
|
// 🔴 孤儿回滚(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');
|
|
|
|
|
|
}
|
2026-08-17 12:25:42 +08:00
|
|
|
|
ctx.logger.error('文件上传失败:', err);
|
|
|
|
|
|
const msg = (err && err.message) ? err.message : '未知错误';
|
|
|
|
|
|
ctx.body = ctx.helper.error(500, msg);
|
2026-09-29 13:17:53 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
// 🔴 释放配额。为何用 `if (ioAcquired)` 而非像 uploadChunk 那样无条件 release:
|
|
|
|
|
|
// 本方法的外层 try 包住了「校验 + 取配额 + 落盘」全过程,而 429 分支是
|
|
|
|
|
|
// **acquire 失败**(配额没拿到)后 return 的 —— 若无条件释放,会把**别人的**配额
|
|
|
|
|
|
// 放掉,配额计数下溢 ⇒ 护栏静默失效(真实并发上限被虚高),是最难查的一类退化。
|
|
|
|
|
|
// ⚠️ 反之也不能省 last 这个 finally:中途任何 return 都不允许泄漏配额,
|
|
|
|
|
|
// 否则配额只减不增,若干轮后上传通道**永久 429**。
|
|
|
|
|
|
if (ioAcquired) releaseFileIo();
|
2026-08-17 12:25:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 上传分片
|
|
|
|
|
|
* POST /api/upload/:group/chunk
|
|
|
|
|
|
*
|
|
|
|
|
|
* Body: multipart/form-data 包含 chunk(文件分片)、chunkIndex、totalChunks、fileId、fileName
|
|
|
|
|
|
*/
|
2026-10-19 13:03:48 +08:00
|
|
|
|
// @no-temp-cleanup: 分片接收端,临时目录必须**保留**待 mergeChunks 合并;
|
|
|
|
|
|
// 若在此清理则分片无法续传。清理责任在 mergeChunks(upload.js:342 safeCleanup)。
|
2026-08-17 12:25:42 +08:00
|
|
|
|
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;
|
2026-09-29 15:06:53 +08:00
|
|
|
|
// 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。
|
|
|
|
|
|
let mergedRelPath = null;
|
|
|
|
|
|
let recordPersisted = false;
|
2026-08-17 12:25:42 +08:00
|
|
|
|
|
|
|
|
|
|
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, '/');
|
2026-09-29 15:06:53 +08:00
|
|
|
|
mergedRelPath = relativePath; // 供 catch 回滚孤儿文件用
|
2026-08-17 12:25:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 保存提交记录
|
|
|
|
|
|
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'
|
|
|
|
|
|
);
|
2026-09-29 15:06:53 +08:00
|
|
|
|
// 入库成功即置位:此后异常不得再删该文件(已被 DB 引用)
|
|
|
|
|
|
recordPersisted = true;
|
2026-08-17 12:25:42 +08:00
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-09-29 15:06:53 +08:00
|
|
|
|
// 🔴 孤儿回滚(C9 · R45):合并结果已落盘但记录未入库 ⇒ 清掉该文件。语义同 upload()。
|
|
|
|
|
|
if (mergedRelPath && !recordPersisted) {
|
|
|
|
|
|
service.upload.removePublicFile(mergedRelPath, 'merge-rollback');
|
|
|
|
|
|
}
|
2026-08-17 12:25:42 +08:00
|
|
|
|
ctx.logger.error('分片合并失败:', err);
|
|
|
|
|
|
ctx.body = ctx.helper.error(500, '分片合并失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 查询分片上传进度
|
|
|
|
|
|
* GET /api/upload/:group/chunk-status?fileId=xxx
|
|
|
|
|
|
*/
|
2026-10-19 13:03:48 +08:00
|
|
|
|
// @no-temp-cleanup: 本方法**只读探测**临时目录(fs.existsSync + readdirSync),
|
|
|
|
|
|
// 不创建也不删除任何文件 ⇒ 「作用域内无清理」是正确形态,不是缺口。
|
2026-08-17 12:25:42 +08:00
|
|
|
|
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;
|