Files
competition-website/app/lib/team_folder.js
qubiaobiao 78f34ea9f5 R194+R195: 小组维度作品列表 / 下载名带小组编号 / includeIdle 口径 / deleteNews 连带清理附件
- service/admin.js
  · getScores & getExamSubmissions 支持 includeIdle(默认隐藏「从未参赛」报名者),
    成绩导出与批量打包强制全量(includeIdle:true),页面开关不影响导出内容
  · 新增 getTeamFolders:小组编号口径统一走 resolveTeamLabels,⛔ 不直接展示 team_id
  · 单个/批量下载包内名统一走 buildTeamDownloadName(中职高职 <小组编号>-<姓名>-<原名>)
  · 🔴 deleteNews:原实现只 News.destroy 不删盘 ⇒ 每轮公示表测试留 4 个孤儿 xlsx。
    改为「先删库后删盘」并连带清理 attachment_url 附件;新增 _removeNewsFile 三重安全闸门
    (仅 /public/news/ 单段文件名 + 自产命名前缀 + path.resolve 不越界)与幂等语义,
    公示表覆盖写复用同一入口(白名单收窄到 news_file_publicity_)
- lib/team_folder.js:新增 buildTeamDownloadName + MAX_DOWNLOAD_NAME=150(防 Windows 255 上限)
- controller/admin.js:透传 includeIdle;batchDownload 接住 bizCode=EMPTY(业务态返 404 非 500);
  中职高职单个大题下载名改走 buildTeamDownloadName

验证:真接口探针 12/12(双清 + 幂等 + 三重闸门);变异 M1(不删盘)1 红、M2(去闸门)3 红,
还原后 12/12 复绿
2026-10-22 09:41:51 +08:00

192 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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('/');
}
/**
* 下载文件名主体(不含扩展名)的字符上限。
*
* 🔴 为什么要有上限:下载名 = 小组编号(≤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 → `未编组_<teamUid>` → `未编组_<aggUid>`)
* 完全复用 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,
};