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:
@@ -558,8 +558,60 @@ class AdminController extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 时间管理 ====================
|
||||
// ==================== 单小组作品压缩包(R190) ====================
|
||||
|
||||
/**
|
||||
* 下载「单个小组」的作品压缩包
|
||||
* GET /api/admin/team-zip/:group?teamUid=xx 或 ?aggregationUid=xx
|
||||
*
|
||||
* 【业务来源】「管理人员下载作品时将这个文件夹进行压缩,直接下载压缩包」
|
||||
* 包内**平铺**该小组的文件(zip 本身即小组目录),文件名保留下传时的原名。
|
||||
*
|
||||
* @returns {Stream} zip 流
|
||||
*/
|
||||
async teamZip() {
|
||||
const { ctx, service } = this;
|
||||
if (!this._ensureAdmin()) return;
|
||||
|
||||
try {
|
||||
const { group } = ctx.params;
|
||||
// 只有本科/教师组有作品文件(中职/高职组走答题答案通道)
|
||||
if (!['benke', 'jiaoshi'].includes(group)) {
|
||||
ctx.body = ctx.helper.error(400, '该组别无作品文件,仅本科组/教师组支持');
|
||||
return;
|
||||
}
|
||||
|
||||
const zipInfo = await service.admin.teamZip(group, {
|
||||
teamUid: ctx.query.teamUid,
|
||||
aggregationUid: ctx.query.aggregationUid,
|
||||
});
|
||||
|
||||
await sendFile(ctx, this.app, {
|
||||
absolutePath: zipInfo.zipFilePath,
|
||||
fileName: zipInfo.downloadName,
|
||||
contentType: 'application/zip',
|
||||
useXAccel: false,
|
||||
});
|
||||
|
||||
// 下载完成后清理临时 zip(Node 流模式下 res.finish 在流发完后触发)
|
||||
ctx.res.on('finish', () => {
|
||||
ctx.helper.safeCleanup(zipInfo.zipFilePath, { label: 'teamZip-zipFile' });
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.bizCode === 'EMPTY') {
|
||||
ctx.body = ctx.helper.error(404, '该小组暂无已上传的作品文件');
|
||||
return;
|
||||
}
|
||||
if (err.bizCode === 'NOT_FOUND') {
|
||||
ctx.body = ctx.helper.error(404, err.message || '未找到该小组');
|
||||
return;
|
||||
}
|
||||
ctx.logger.error('小组作品打包失败:', err);
|
||||
ctx.body = ctx.helper.error(500, '小组作品打包失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 时间管理 ====================
|
||||
/**
|
||||
* 批量设置比赛时间
|
||||
* PUT /api/admin/times
|
||||
|
||||
Reference in New Issue
Block a user