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 步。
This commit is contained in:
@@ -107,6 +107,25 @@ class UploadController extends Controller {
|
||||
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) {
|
||||
@@ -132,15 +151,18 @@ class UploadController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group);
|
||||
savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用
|
||||
|
||||
// 获取作品名称(流式模式字段在 stream.fields 中)
|
||||
// 获取作品名称与文件类型(流式模式字段在 stream.fields 中)。
|
||||
// ⚠️ 必须先取再落盘:落盘要用 file_type 判定「同名文件是否同一次提交的新版本」。
|
||||
const workName = (stream.fields && stream.fields.work_name) || stream.filename;
|
||||
// 文件类型:word / ppt(前端上传时透传)
|
||||
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,
|
||||
@@ -267,6 +289,8 @@ class UploadController extends Controller {
|
||||
const { ctx, service, app } = this;
|
||||
// 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。
|
||||
let mergedRelPath = null;
|
||||
// 合并临时文件(先写临时名再原子 rename)。失败时需清掉,否则留下 `.merge_*` 残渣。
|
||||
let mergedTmpPath = null;
|
||||
let recordPersisted = false;
|
||||
|
||||
try {
|
||||
@@ -293,6 +317,22 @@ class UploadController extends Controller {
|
||||
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)) {
|
||||
@@ -312,16 +352,20 @@ class UploadController extends Controller {
|
||||
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);
|
||||
// 构建落盘目标:小组目录 + 原始文件名(唯一命名实现 = 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/事件循环耗尽雪崩。
|
||||
@@ -334,8 +378,8 @@ class UploadController extends Controller {
|
||||
}
|
||||
|
||||
try {
|
||||
// 合并分片
|
||||
const mergeResult = await service.upload.mergeFileChunks(chunkFiles, targetPath);
|
||||
// 合并分片(落到临时名)
|
||||
await service.upload.mergeFileChunks(chunkFiles, mergedTmpPath);
|
||||
|
||||
// 🔴 删除临时目录:best-effort,**绝不能让清理失败把已成功的合并判成 500**。
|
||||
// 旧代码是裸 fs.rmdirSync,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会
|
||||
@@ -343,17 +387,11 @@ class UploadController extends Controller {
|
||||
// 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。
|
||||
ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' });
|
||||
|
||||
// 构建相对路径
|
||||
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
|
||||
mergedRelPath = relativePath; // 供 catch 回滚孤儿文件用
|
||||
// 原子落位 + 计算 size/md5(filePath 为相对 app/public 的正斜杠路径)
|
||||
const fileInfo = await service.upload.commitWorkFile(mergedTmpPath, target);
|
||||
mergedRelPath = fileInfo.filePath; // 供 catch 回滚孤儿文件用
|
||||
|
||||
// 保存提交记录
|
||||
const fileInfo = {
|
||||
filePath: relativePath,
|
||||
fileName,
|
||||
fileSize: mergeResult.fileSize,
|
||||
md5: mergeResult.md5,
|
||||
};
|
||||
const record = await service.upload.saveSubmissionRecord(
|
||||
aggregation.aggregation_uid,
|
||||
group,
|
||||
@@ -367,11 +405,11 @@ class UploadController extends Controller {
|
||||
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,
|
||||
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 {
|
||||
@@ -382,6 +420,10 @@ class UploadController extends Controller {
|
||||
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, '分片合并失败');
|
||||
}
|
||||
@@ -448,16 +490,33 @@ class UploadController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查提交状态
|
||||
const submitted = await service.upload.hasSubmitted(aggregation.aggregation_uid, group);
|
||||
// 检查提交状态(本人)+ 组内互斥状态(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 deadlineCheck = await service.upload.validateDeadline(group);
|
||||
// 🔴 组锁状态必须暴露给前端,否则被锁成员会看到「未上传」却任何操作都失败,
|
||||
// 完全不知道原因(同一类「状态不可见」缺陷在考试组锁上已踩过)。
|
||||
const canUpload = deadlineCheck.valid && !teamState.locked;
|
||||
|
||||
ctx.body = ctx.helper.success({
|
||||
submitted,
|
||||
submitted, // 本人是否已上传
|
||||
deadline: deadlineCheck.deadline,
|
||||
canSubmit: deadlineCheck.valid,
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user