'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('/'); } module.exports = { MAX_SEGMENT, sanitizeSegment, sanitizeFileName, resolveTeamFolderName, teamFolderRelative, };