'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) * ② 无编号但有队伍归属 ⇒ `未编组_`(同队成员仍归到同一目录) * ③ 连队伍归属都没有(单人参赛 / 历史脏数据)⇒ `未编组_`(每人独立,防撞车) * * @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('/'); } /** * 下载文件名主体(不含扩展名)的字符上限。 * * 🔴 为什么要有上限:下载名 = 小组编号(≤60) + 姓名(≤60) + 原文件名(≤120), * 三者直接拼接可到 240 字符。Windows 资源管理器对单文件名上限 255 **字符** * (NTFS 255 UTF-16 单元),240 已贴边,再加上浏览器自动加的 `(1)` 后缀就会失败。 * 故统一收敛到 150,保证「编号 + 姓名 + 关键文件名」都能看见,又不触系统上限。 */ const MAX_DOWNLOAD_NAME = 150; /** * 拼接「管理端下载文件名」—— 中职/高职大题(Word)单个下载与批量 ZIP 包内名共用。 * * 格式:`<小组编号>-<姓名>-<原文件名>`(R195 需求:小组编号放最前) * 例:`BRICS2026-ST-027-94001-张三-大题作答.docx` * * 🔴 单一口径:小组编号三态(teamNumber → `未编组_` → `未编组_`) * 完全复用 resolveTeamFolderName,与本组上传落盘目录名**必然一致** * ⇒ 管理员把下载文件解压回工作目录时,文件名前缀可直接对上作品目录。 * ⛔ 不得在 controller / service 各拼一套(尤其是「有队伍但 teams 表缺元信息」这一档, * 两处拼法分叉后会出现「同一个人下载出两个不一样的前缀」)。 * * @param {Object} p * @param {?string} p.teamNumber - teams.team_number || teams.team_id * @param {?number} p.teamUid - 队伍号(组内唯一) * @param {?number} p.aggregationUid - 聚合报名 ID(最终兜底,防多人同名前缀重号) * @param {?string} p.personName - 选手姓名(缺省则跳过该段) * @param {?string} p.originalName - 原文件名(选手上传名) * @param {string} [p.fallbackStem] - 原文件名为空时的兜底主体 * @returns {string} 安全文件名(非空、无路径分隔符、长度受限) */ function buildTeamDownloadName({ teamNumber, teamUid, aggregationUid, personName, originalName, fallbackStem = '大题作答', } = {}) { const prefix = resolveTeamFolderName({ teamNumber, teamUid, aggregationUid }); const person = personName === null || personName === undefined ? '' : sanitizeSegment(personName, ''); const base = sanitizeFileName(originalName, fallbackStem); // 拆扩展名:截断只能砍主体,⛔ 不能把 `.docx` 砍掉(砍掉后浏览器会存成未知类型) const dot = base.lastIndexOf('.'); const stem = dot > 0 ? base.slice(0, dot) : base; const ext = dot > 0 ? base.slice(dot) : ''; const head = [prefix, person].filter(Boolean).join('-'); // 预留 1 个字符给分隔符 '-' const room = MAX_DOWNLOAD_NAME - head.length - ext.length - 1; const finalStem = stem.length > room ? stem.slice(0, Math.max(1, room)).trim() : stem; return `${head}-${finalStem}${ext}`; } module.exports = { MAX_SEGMENT, MAX_DOWNLOAD_NAME, sanitizeSegment, sanitizeFileName, resolveTeamFolderName, teamFolderRelative, buildTeamDownloadName, };