'use strict' /** * 上传文件名归一化。 * * 背景(生产事故复盘 2026-08-10): * 备赛资料上传曾出现「列表读取 ENOENT」——磁盘上的文件名是乱码(GBK 字节被按 * UTF-8 误解码)+ 前导空格,导致 admin.getMaterials 在 readdirSync().map(statSync) * 时 stat 不到该文件而整列抛错。根因是 uploadMaterial 直接拿 stream.filename 当 * 磁盘名、未做编码/空白归一化。 * * 本函数对传入的「客户端原始文件名」做最小但充分的清洗,保证落盘名: * - 是合法 UTF-8(NFC 归一化,消除组合字符/变体差异) * - 不含控制字符与路径分隔符(防目录穿越 + 防 stat 失败) * - 不含前导/多余空白(折叠为单空格并 trim —— 直接修复「前导空格 + 乱码」类 ENOENT) * - 长度受限(保留扩展名) * 若清洗后为空(极端情况),调用方应回退到时间戳名,避免写出空名文件。 * * 注意:本函数不负责「还原」客户端用错误编码(如 GBK)发来的文件名——那是客户端 * 规范问题;它的职责是「让落盘名干净、可往返、不会让读盘崩溃」。已落盘的旧乱码文件 * 需运维侧手动 rm(见 diag_orphan + 人工清理)。 */ function sanitizeUploadFileName(rawName, fallbackExt = '') { if (!rawName || typeof rawName !== 'string') return '' let name = rawName // 1) 客户端可能预编码(如 %E5%89%8D...),先尝试解码 if (name.includes('%') && /%[0-9A-Fa-f]{2}/.test(name)) { try { name = decodeURIComponent(name) } catch (e) { /* 解码失败保留原串 */ } } // 2) Unicode 归一化(NFC),消除同一字符的不同编码形态 try { if (typeof name.normalize === 'function') name = name.normalize('NFC') } catch (e) { /* 兜底保留 */ } // 3) 去除控制字符(C0: 0x00-0x1F,DEL/0x7F)与路径分隔符/危险字符 // 控制字符在正则里用 \x00-\x1f 与 \x7f 表示,避免源码里写裸控制字节 name = name.replace(/[\x00-\x1f\x7f]/g, '') name = name.replace(/[\/\\:*?"<>|]/g, '_') // 4) 折叠所有空白(含全角/不间断空格等 \s 覆盖的字符)为单空格并 trim name = name.replace(/\s+/g, ' ').trim() // 5) 去除首尾点(避免 ".name" / "name." / ".." 类) name = name.replace(/^[. ]+/, '').replace(/[. ]+$/, '') // 6) 长度限制(保留扩展名) const MAX = 120 if (name.length > MAX) { const dot = name.lastIndexOf('.') const ext = dot > 0 ? name.slice(dot) : '' const safeExt = ext || fallbackExt || '' name = name.slice(0, MAX - safeExt.length) + safeExt } return name } module.exports = { sanitizeUploadFileName }