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:
2026-10-21 15:20:42 +08:00
parent de719d8ec6
commit 0a380371ec
7 changed files with 849 additions and 144 deletions

View File

@@ -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