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 复绿
This commit is contained in:
2026-10-22 09:41:51 +08:00
parent 0a380371ec
commit 78f34ea9f5
3 changed files with 253 additions and 24 deletions

View File

@@ -123,10 +123,69 @@ function teamFolderRelative(group, 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,
};