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

132
app/lib/team_folder.js Normal file
View File

@@ -0,0 +1,132 @@
'use strict';
/**
* 小组作品目录命名 —— **单一 SoT**
*
* 【业务来源】R190 管理人员反馈:
* 「参赛小组上传作品时由于他们的作品分为两个文件且会自行命名,所以进行评分时
* 两个文件分开下载经常出现混乱,现在提出了想法,当参赛小组上传作品时,后台
* 自动生成一个以他们小组编号命名的文件夹,将他们的作品存放在这个文件夹中。」
*
* 【本模块的唯一职责】把「一个小组」映射成一个**安全、可读、稳定**的目录名。
* · 上传落盘(service/upload.js)与管理端打包(service/admin.js)**必须**都调这里
* —— 否则「上传写进 A 目录、下载从 B 目录找」的错位会在上线后立刻炸。
* · 前端**不参与**命名,只展示后端回传的 folderName / teamNumber。
*
* 🔴 与既有口径的关系(⛔ 不得另立第二套):
* 小组编号的展示值沿用 `service/admin.js#resolveTeamLabels` 的
* `teams.team_number || teams.team_id`;本模块只负责把它**变成合法目录名**,
* 不重新定义「哪个字段算编号」。
*
* 🔴 为什么必须净化:目录名直接参与 `fs.mkdir` / `path.join`。
* `team_number` 来自报名表导入,历史上出现过带 `/` `\` 的值会被当成路径分隔符
* ⇒ 文件落到意料之外的目录(甚至穿越到 works 之外)。故:
* · 替换 Windows 非法字符 `\ / : * ? " < > |` 与控制字符
* · 去掉首尾空白与首尾点(Windows 不允许尾点,且 `..` 是穿越向量)
* · 限长 60 字符(避免超过单段 255 字节上限,也给中文留余量)
*/
/** 单段目录名/文件名主体的最大长度(字符数,非字节) */
const MAX_SEGMENT = 60;
/** 非法字符:Windows 保留字符 + 控制字符 */
// eslint-disable-next-line no-control-regex
const ILLEGAL_CHARS = /[\\/:*?"<>|\u0000-\u001f\u007f]/g;
/** Windows 设备保留名(不区分大小写,含扩展名前的部分) */
const RESERVED_NAMES = new Set([
'con', 'prn', 'aux', 'nul',
'com0', 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
'lpt0', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
]);
/**
* 净化单个路径段(目录名或文件名主体)。
*
* @param {*} raw - 原始值(任意类型,null/undefined 视为空)
* @param {string} [fallback] - 净化后为空时的兜底值
* @returns {string} 安全的单段名称
*/
function sanitizeSegment(raw, fallback = '未命名') {
let s = String(raw === null || raw === undefined ? '' : raw);
s = s.replace(ILLEGAL_CHARS, '_');
// 折叠空白(含全角空格转半角后折叠),去掉首尾空白
s = s.replace(/\u3000/g, ' ').replace(/\s+/g, ' ').trim();
// 去掉首部点(`.` / `..` / `...foo` 都会被误判)与尾部点
s = s.replace(/^\.+/, '').replace(/\.+$/, '').trim();
if (s.length > MAX_SEGMENT) s = s.slice(0, MAX_SEGMENT).trim();
if (!s) return fallback;
// 保留名兜底:`CON` → `CON_`
if (RESERVED_NAMES.has(s.toLowerCase())) return s + '_';
return s;
}
/**
* 净化一个**文件名**(保留扩展名,主体走 sanitizeSegment)。
*
* @param {*} raw - 原始文件名(可能带路径)
* @param {string} [fallbackStem] - 主体为空时的兜底
* @param {string} [fallbackExt] - 无扩展名时的兜底扩展名(含点,如 '.pdf')
* @returns {string} 安全文件名
*/
function sanitizeFileName(raw, fallbackStem = '作品', fallbackExt = '') {
const original = String(raw === null || raw === undefined ? '' : raw);
// 只取 basename:同时切开 `/` 与 `\`(path.basename 在 Linux 上不切 `\`)
const base = original.replace(/^.*[\\/]/, '');
const dot = base.lastIndexOf('.');
const hasExt = dot > 0 && dot < base.length - 1;
const rawStem = hasExt ? base.slice(0, dot) : base;
const rawExt = hasExt ? base.slice(dot + 1) : '';
// 扩展名只允许字母数字,最长 10 位;出现异常字符即视为无扩展名
const ext = /^[A-Za-z0-9]{1,10}$/.test(rawExt) ? '.' + rawExt.toLowerCase() : fallbackExt;
const stem = sanitizeSegment(rawStem, fallbackStem);
return stem + ext;
}
/**
* 解析「小组作品目录名」—— 上传落盘与管理端打包共用的唯一入口。
*
* 三态优先级(与 `resolveTeamLabels` 的 teamNumber 口径对齐):
* ① `teamNumber`(= `teams.team_number || teams.team_id`,人类可读编号,如 BRICS2026-ST-027-94001)
* ② 无编号但有队伍归属 ⇒ `未编组_<teamUid>`(同队成员仍归到同一目录)
* ③ 连队伍归属都没有(单人参赛 / 历史脏数据)⇒ `未编组_<aggregationUid>`(每人独立,防撞车)
*
* @param {Object} p
* @param {?string} p.teamNumber - 人类可读小组编号
* @param {?number} p.teamUid - 队伍号(组内唯一)
* @param {?number} p.aggregationUid - 聚合报名 ID(最终兜底)
* @returns {string} 安全的目录名(保证非空、无路径分隔符)
*/
function resolveTeamFolderName({ teamNumber, teamUid, aggregationUid } = {}) {
const label = teamNumber === null || teamNumber === undefined ? '' : String(teamNumber).trim();
if (label) return sanitizeSegment(label, '未编组');
if (teamUid !== null && teamUid !== undefined && String(teamUid).trim() !== '') {
return sanitizeSegment(`未编组_${teamUid}`, '未编组');
}
if (aggregationUid !== null && aggregationUid !== undefined && String(aggregationUid).trim() !== '') {
return sanitizeSegment(`未编组_${aggregationUid}`, '未编组');
}
return '未编组';
}
/**
* 拼接「小组作品目录」的相对路径(相对 `app/public`,正斜杠)。
*
* @param {string} group - 组别
* @param {string} folderName - resolveTeamFolderName 的返回值
* @returns {string} 如 `works/benke/BRICS2026-ST-027-94001`
*/
function teamFolderRelative(group, folderName) {
return ['works', sanitizeSegment(group, 'unknown'), sanitizeSegment(folderName, '未编组')]
.join('/');
}
module.exports = {
MAX_SEGMENT,
sanitizeSegment,
sanitizeFileName,
resolveTeamFolderName,
teamFolderRelative,
};