Files
qubiaobiao 0a380371ec feat(upload): 组内作品唯一 + 小组编号目录 + 按小组打包下载(R190)
需求(管理人员反馈):
① 本科组/教师组同样遵循「小组内只要有一人上传作品,其他人就不能再上传」;
② 作品按「小组编号」建目录存放并保留原始文件名,管理端下载时按小组打包。

后端落地:
- 组锁:新增 getTeamUploadState/assertTeamUploadAllowed,按 (group, teamUid) 取全组
  提交记录,**最早提交者为归属人**;其他人上传与分片合并两条路径都返 6006
  (data 带 teamLocked/teamOwnerName/teamNumber/folderName)。删除本人作品后自动解锁。
  ⛔ 必须先按 group 过滤再认 teamUid(team_uid 仅组内唯一)。
- 落盘:works/<group>/<小组编号>/<原始文件名>。目录名走新增 lib/team_folder.js
  (唯一 SoT:净化 Windows 非法字符/控制字符/首尾点/设备保留名,限长 60)。
  撞名三态:不存在→原名;同一聚合+同一 file_type→原名原子覆盖;否则 (2)(3)。
  DB file_path 仍为相对路径 ⇒ 存量平铺文件免迁移。
- 直传与分片合并收敛到同一套 prepareWorkTarget + commitWorkFile:先写同目录临时名
  (.upload_*/.merge_*),再 renameSync;失败一律 safeCleanup 后抛。
- status 接口新增 teamLocked/canUpload/teamOwnerName/isTeamOwner/teamFiles/memberCount。
- 管理端:works 列表返回 teamUid/teamNumber/teamFolder(走 resolveTeamLabels 既有口径);
  新增 GET /api/admin/team-zip/:group?teamUid= 单组打包(仅本科/教师,其余 400,包内平铺);
  batch-download 包内改为 <小组编号>/<原始文件名>。两者共用 _buildZip。

验证:真接口 45/45 + 变异 M1(locked=false ⇒ 15 条断言红,还原后复绿);
已收编为门禁第 58 步。
2026-10-21 15:20:42 +08:00

601 lines
24 KiB
JavaScript
Raw Permalink 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;
}
// 🔴 R190 组内上传互斥:本科/教师组「小组中只要有一人上传,其他人就不能上传」,
// 替换权只归组内最早提交者(归属人)。⚠️ 必须放在 ctx.getFileStream() **之前** ——
// 被锁成员根本不该把几十 MB 请求体传上来再被拒(白耗入带宽 + 通道配额)。
const gate = await service.upload.assertTeamUploadAllowed(aggregation, group);
if (!gate.ok) {
// 业务错误 = HTTP 200 + code≠0(helper.error 不改 HTTP 状态),
// 附加 data 供前端在按钮/提示里展示归属人,message 已含完整可读文案。
const body = ctx.helper.error(6006, gate.message);
body.data = {
teamLocked: true,
teamOwnerName: gate.state.owner && gate.state.owner.name ? gate.state.owner.name : null,
teamNumber: gate.state.teamNumber,
folderName: gate.state.folderName,
};
ctx.body = body;
return;
}
const teamFolder = gate.state.folderName;
// 获取上传的文件流
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;
}
// 获取作品名称与文件类型(流式模式字段在 stream.fields 中)。
// ⚠️ 必须先取再落盘:落盘要用 file_type 判定「同名文件是否同一次提交的新版本」。
const workName = (stream.fields && stream.fields.work_name) || stream.filename;
const fileType = (stream.fields && stream.fields.file_type) || 'word';
// 保存文件:落点 = works/<group>/<小组编号>/<原始文件名>
const fileInfo = await service.upload.saveWorkFile(
stream, stream.filename, group, teamFolder,
{ fileType, aggregationUid: aggregation.aggregation_uid }
);
savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用
// 保存提交记录
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
*/
// @no-temp-cleanup: 分片接收端,临时目录必须**保留**待 mergeChunks 合并;
// 若在此清理则分片无法续传。清理责任在 mergeChunks(upload.js:342 safeCleanup)。
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;
// 合并临时文件(先写临时名再原子 rename)。失败时需清掉,否则留下 `.merge_*` 残渣。
let mergedTmpPath = 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;
}
// 🔴 R190 组内上传互斥:与 upload() 同一个守卫(两条落盘路径都必须拦,
// 否则被锁成员改走分片合并即可绕过组内锁)。
const gate = await service.upload.assertTeamUploadAllowed(aggregation, group);
if (!gate.ok) {
const body = ctx.helper.error(6006, gate.message);
body.data = {
teamLocked: true,
teamOwnerName: gate.state.owner && gate.state.owner.name ? gate.state.owner.name : null,
teamNumber: gate.state.teamNumber,
folderName: gate.state.folderName,
};
ctx.body = body;
return;
}
const teamFolder = gate.state.folderName;
// 检查分片目录
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;
}
// 构建落盘目标:小组目录 + 原始文件名(唯一命名实现 = service.prepareWorkTarget,
// 与 saveWorkFile 同源,⛔ 不得在此另写一份随机命名)。
const target = await service.upload.prepareWorkTarget({
group,
folderName: teamFolder,
filename: fileName,
fileType: file_type || 'word',
aggregationUid: aggregation.aggregation_uid,
});
// 先合并到临时名,再原子 rename 到最终名 —— 避免管理员在合并过程中下载到半截文件
mergedTmpPath = path.join(
target.dir,
`.merge_${Date.now()}_${Math.random().toString(36).substring(2, 8)}${path.extname(target.fileName)}`
);
// 🔴 文件 IO 并发护栏:分片合并是重 IO,与 uploadChunk 共享同一配额,
// 极端并发(无带宽限制 + 单 worker)下被统一限流,避免 fd/事件循环耗尽雪崩。
try {
await acquireFileIo();
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
return;
}
try {
// 合并分片(落到临时名)
await service.upload.mergeFileChunks(chunkFiles, mergedTmpPath);
// 🔴 删除临时目录:best-effort,**绝不能让清理失败把已成功的合并判成 500**。
// 旧代码是裸 fs.rmdirSync,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会
// 抛到外层 catch → 返回「分片合并失败」,而目标文件已落盘且 DB 记录还没写,
// 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。
ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' });
// 原子落位 + 计算 size/md5(filePath 为相对 app/public 的正斜杠路径)
const fileInfo = await service.upload.commitWorkFile(mergedTmpPath, target);
mergedRelPath = fileInfo.filePath; // 供 catch 回滚孤儿文件用
// 保存提交记录
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: fileInfo.filePath,
fileUrl: ctx.helper.fileUrl(fileInfo.filePath),
fileName: fileInfo.fileName,
fileSize: fileInfo.fileSize,
md5: fileInfo.md5,
uploadTime: record.upload_time || new Date().toISOString(),
}, '分片合并成功');
} finally {
releaseFileIo();
}
} catch (err) {
// 🔴 孤儿回滚(C9 · R45):合并结果已落盘但记录未入库 ⇒ 清掉该文件。语义同 upload()。
if (mergedRelPath && !recordPersisted) {
service.upload.removePublicFile(mergedRelPath, 'merge-rollback');
}
// 合并中途失败 ⇒ 临时名文件也必须清掉(best-effort,绝不掩盖真实错误)
if (mergedTmpPath && !mergedRelPath) {
ctx.helper.safeCleanup(mergedTmpPath, { label: 'mergeChunks-failed' });
}
ctx.logger.error('分片合并失败:', err);
ctx.body = ctx.helper.error(500, '分片合并失败');
}
}
/**
* 查询分片上传进度
* GET /api/upload/:group/chunk-status?fileId=xxx
*/
// @no-temp-cleanup: 本方法**只读探测**临时目录(fs.existsSync + readdirSync),
// 不创建也不删除任何文件 ⇒ 「作用域内无清理」是正确形态,不是缺口。
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;
}
// 检查提交状态(本人)+ 组内互斥状态(R190)
const [submitted, teamState, deadlineCheck] = await Promise.all([
service.upload.hasSubmitted(aggregation.aggregation_uid, group),
service.upload.getTeamUploadState(aggregation, group),
service.upload.validateDeadline(group),
]);
// 🔴 组锁状态必须暴露给前端,否则被锁成员会看到「未上传」却任何操作都失败,
// 完全不知道原因(同一类「状态不可见」缺陷在考试组锁上已踩过)。
const canUpload = deadlineCheck.valid && !teamState.locked;
ctx.body = ctx.helper.success({
submitted, // 本人是否已上传
deadline: deadlineCheck.deadline,
canSubmit: canUpload, // 兼容旧字段:语义 = 现在能不能上传
canUpload,
deadlineValid: deadlineCheck.valid,
// ---- 小组维度 ----
teamNumber: teamState.teamNumber, // 人类可读小组编号(teams.team_number || team_id)
folderName: teamState.folderName, // 后端落盘用的目录名(前端只展示,不参与命名)
memberCount: teamState.memberCount,
teamSubmitted: teamState.hasAnySubmission, // 组内是否已有人上传
teamOwnerName: teamState.owner ? teamState.owner.name : null,
isTeamOwner: teamState.isOwner,
teamLocked: teamState.locked, // true = 组内别人已上传,本人不能再传
teamLockMessage: teamState.lockMessage,
teamFiles: teamState.teamFiles, // 组内已上传文件清单(含上传者)
}, '获取提交状态成功');
} 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;