chore: 后端首次基线提交

运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。
已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
This commit is contained in:
WorkBuddy
2026-08-17 12:25:42 +08:00
commit a303cf2d53
109 changed files with 25904 additions and 0 deletions

62
app/utils/filename.js Normal file
View File

@@ -0,0 +1,62 @@
'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 }