做后台系统开发的朋友,早晚会遇到这个需求:页面上放一个上传按钮,能一次选中整个文件夹,把里面的文件连同子目录一起传到服务器。这种“文件夹上传”和单文件上传完全是两码事,难点不在接收文件本身,而在怎么保证目录结构不丢、服务器不卡、文件不冲突、路径不越界。这篇文章我把实际项目里用到的做法整理一遍,从最朴素的前端目录选择、后端落盘,到应对大目录的Zip方案和分片思路,再到安全配置和踩坑记录,适合刚接手这个功能的人,也适合想把自己上传模块改得更稳一点的开发者。
1. 文件夹上传的痛点与“高效”的定义
1.1 文件夹上传和单文件上传的底层差异
单文件上传背后只有一个文件流,后端拿到文件名直接写盘就行。文件夹上传表面上看是“多个文件”,本质上是“一组携带相对路径的文件”。比如你选了项目源码目录,里面有src/utils/http.ts和docs/readme.md,服务器收到后必须在存储目录下重建出src/utils/和docs/这两个子目录,再把文件放进去。
这个差异带来几个连锁问题:前端怎么把相对路径传给后端?后端怎么区分同名文件?如果用户传了两个不同目录下的同名文件,只拿FileName保存就会互相覆盖。另外,浏览器安全模型不允许网页拿到客户端的绝对路径,你只能拿到以所选文件夹为根节点的相对路径,这个信息来自File.webkitRelativePath(现代浏览器普遍支持)。
还有一个很容易忽略的点:即使选择了一个包含空子目录的文件夹,input.files里也不会出现空目录,因为目录本身不是文件。如果你的业务必须保留空目录结构,要么前端额外把目录清单序列化提交,要么后端在创建目录时根据预先提交的清单补建。我在项目里遇到过客户要求保留完整的日志目录结构,结果空目录全丢了,后来就是在前端遍历webkitRelativePath时顺便收集目录路径清单。
1.2 高效不是一个速度指标,而是一组约束
很多人以为“高效”就是把上传速度拉到最大,并行开满,文件疯狂往磁盘写。真正做过之后会发现,高效的前提是稳定:超大目录不超时、不上千个并发把磁盘IO打爆、不出现半截文件、失败之后可以重试而不必重传整个目录。我觉得高效的文件夹上传应该满足这几个约束:
- 传输层:单请求能承载尽量多的文件,或者通过分片/压缩减少请求次数;
- 处理层:流式读写,不把整个文件或整个请求体读进内存;
- 持久层:写入操作尽量做到可续传、可幂等,重试不会产生脏数据;
- 交互层:用户能看到进度,能判断“是卡死了还在传输中”。
这组约束直接决定了我在后面的设计取舍。比如有人会用ReadAllBytes一次性把文件读入内存再写盘,小文件没感觉,遇到一个 2GB 的压缩包进程内存直接飙升,这在服务器上是绝对不能接受的。
2. 前端如何把整个文件夹完整交给后端
2.1 用webkitdirectory让input直接选择整个目录
最基础的方式是在<input>上加上webkitdirectory和multiple属性:
<input type="file" id="folderInput" webkitdirectory multiple />webkitdirectory不是标准属性,但主流浏览器都实现了,Chrome、Edge、Firefox 都能用。加上它之后,用户点击弹出的就是目录选择对话框,而不是文件选择框。还有几个兼容属性directory、mozdirectory可以一起写,不过实测现代浏览器基本只看webkitdirectory。如果你要兼容拖拽上传,那就得走DataTransferItem.webkitGetAsEntry(),它可以拿到目录入口,再递归读取目录树里的文件列表。这个API稍复杂,但在交互上更自然,适合做拖拽整个文件夹的场景。
拿到input.files之后,遍历每一项,每一项都有webkitRelativePath,形如assets/css/app.css,其中assets就是用户选择的文件夹名。注意,这个路径的分隔符永远是正斜杠/,与服务器操作系统无关,后端需要自行转换成当前平台的目录分隔符。
2.2 用FormData把相对路径和文件一起提交
很多初学者踩的第一个坑:前端明明选了文件夹,后端收到的却是一堆平铺的文件名,目录结构完全丢失。原因就是没把相对路径传到后端。相对路径的传递方式有讲究,我踩过两次坑之后固定用下面的方案:
const input = document.getElementById('folderInput'); input.addEventListener('change', async () => { const files = Array.from(input.files); const totalSize = files.reduce((sum, f) => sum + f.size, 0); // 实际项目里建议做一个分流:中小目录直接传,超大目录走分片 if (totalSize > 200 * 1024 * 1024) { alert('目录过大,请使用分片上传入口'); return; } const formData = new FormData(); const paths = []; for (const file of files) { formData.append('files', file); paths.push(file.webkitRelativePath || file.name); } // 把路径列表整体序列化成一个字段,避免几千个独立表单字段 formData.append('pathsJson', JSON.stringify(paths)); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/folder/upload'); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); // 更新页面进度条 } }; xhr.onload = () => { if (xhr.status === 200) { // 上传成功 } else { // 读取后端返回的错误信息 } }; xhr.onerror = () => { // 网络层失败 }; xhr.send(formData); });注意我在formData.append('files', file)时没有指定第三个参数,也就是没有自定义文件名,后端拿到的IFormFile.FileName就是文件原名。目录信息完全依靠pathsJson里的相对路径来还原。为什么要绕一圈?因为如果你用formData.append('files', file, file.webkitRelativePath)这种方式把完整相对路径塞进文件名,不同浏览器对带/路径的处理并不完全一致,有的会保留路径,有的会把/替换成\,后端解析时又要做兼容。与其在文件名这个不确定的字段上做文章,不如把路径单独放一个字段,后端直接用,简单可控。
还有一点,XHR 的上传进度是真实的网络传输进度,但它只覆盖“浏览器把数据发出去了”,不包含后端写入磁盘的耗时。如果你想知道后端解压或写盘的进度,需要额外用 SignalR 或者轮询接口,这个我在第4部分细说。
2.3 使用XHR而不是fetch的关键原因
如果你用fetch提交 FormData,代码确实简洁,但它没有上传进度事件。fetch虽然底层也有流式能力,但要在浏览器支持不够统一的地方做进度模拟,非常麻烦。所以在文件上传场景,我倾向于直接用XMLHttpRequest,靠xhr.upload.onprogress拿进度,代码略微啰嗦,但稳定可靠。如果你同时需要支持取消上传,XHR 的xhr.abort()也足够直接。
如果项目启用了 ASP.NET Core 的防伪验证(Antiforgery),手动发 XHR 是拿不到隐藏表单字段的,需要在请求头里带上令牌:
const token = document.querySelector('input[name="__RequestVerificationToken"]').value; xhr.setRequestHeader('RequestVerificationToken', token);否则后端返回 400,这个坑在前后端分离或者纯 Ajax 提交时特别常见。
3. 后端落盘:从相对路径到安全可靠的目录重建
3.1 后端接收:使用IFormCollection读取文件与路径列表
先看完整的接收代码:
[HttpPost("upload")] [RequestSizeLimit(2L * 1024 * 1024 * 1024)] // 2GB,按需调整 public async Task<IActionResult> UploadFolder() { var form = await Request.ReadFormAsync(); var files = form.Files; var pathsJson = form["pathsJson"].ToString(); if (string.IsNullOrEmpty(pathsJson)) { return BadRequest(new { message = "缺少文件路径信息" }); } var paths = JsonSerializer.Deserialize<List<string>>(pathsJson); if (paths == null || paths.Count != files.Count) { return BadRequest(new { message = "文件数量与路径数量不一致" }); } var root = Path.GetFullPath(_options.UploadRootDirectory); Directory.CreateDirectory(root); for (var i = 0; i < files.Count; i++) { var file = files[i]; var relativePath = paths[i]; var safePath = BuildSafePath(root, relativePath); // 防止路径穿越 var resolved = Path.GetFullPath(Path.Combine(root, safePath)); if (!resolved.StartsWith(root, StringComparison.OrdinalIgnoreCase)) { return BadRequest(new { message = $"非法路径: {relativePath}" }); } var dir = Path.GetDirectoryName(resolved); if (!string.IsNullOrEmpty(dir)) { Directory.CreateDirectory(dir); } resolved = EnsureUniquePath(resolved); await using var stream = new FileStream(resolved, FileMode.Create); await file.CopyToAsync(stream); } return Ok(new { count = files.Count }); }这里有两个关键方法需要实现:BuildSafePath负责把前端传来的路径清洗成合法相对路径,EnsureUniquePath负责处理重名文件。
BuildSafePath要做的事包括:把/和\统一替换成当前平台的目录分隔符;去掉路径中每个段的非法字符;过滤掉连续的目录分隔符。Path.GetInvalidFileNameChars()可以帮我们拿到系统非法字符集合,但要注意目录段的非法字符和文件名的非法字符略有差异,简单起见我会对每个路径段整体过滤,把非法字符替换成_。
路径清洗还有一个隐蔽点:Windows 下文件名不能是CON、PRN、AUX、NUL、COM1这类保留设备名。虽然概率不高,但用户目录里真的可能出现名为aux.txt的文件,直接创建在 Windows 上会报错。遇到这种情况建议统一在文件名前面加_。
EnsureUniquePath就是一个循环判断:如果文件已存在,就在文件名主干后面加_1、_2,直到找到一个不冲突的名字。覆盖用户文件是不可接受的,静默跳过也会让用户困惑,我倾向于自动改名并记录日志,至少保证数据不丢。
private static string EnsureUniquePath(string path) { if (!File.Exists(path)) return path; var dir = Path.GetDirectoryName(path)!; var name = Path.GetFileNameWithoutExtension(path); var ext = Path.GetExtension(path); for (var i = 1; i < 1000; i++) { var candidate = Path.Combine(dir, $"{name}_{i}{ext}"); if (!File.Exists(candidate)) return candidate; } throw new IOException("同名文件过多,无法自动重命名"); }3.2 为什么必须用流式写入而不是ReadAllBytes
上面代码里最关键的一行是await file.CopyToAsync(stream)。IFormFile.CopyToAsync内部会对文件流做固定缓冲区的复制,内存占用基本可控。
如果你用的是var bytes = await file.GetBytes(),虽然IFormFile本身有OpenReadStream(),但很多新手会顺手写成file.CopyTo(new MemoryStream()),甚至用ReadAllBytes再写盘。这在单文件几MB时毫无感知,一旦文件达到几百MB或上GB,内存瞬间飙升,服务器上跑着其他业务就可能直接 OOM。流式写入的另一个好处是边收边写,浏览器上传还没结束,文件已经有一部分落盘了,整体耗时更低。
有一个性能细节值得提:File.Create的默认缓冲区大小是 4096 字节,对于大文件写入偏小。可以在FileStream构造函数里显式指定缓冲区,比如:
await using var stream = new FileStream(resolved, FileMode.Create, FileAccess.Write, FileShare.None, 81920);8KB 到 80KB 的缓冲区在机械硬盘和 SSD 上表现都不错,太小会增加系统调用次数,太大又浪费内存。具体数值可以压测调整,不用迷信某个固定值。
3.3 大批量小文件的并发处理需要克制
实际项目里最难受的不是传几个大文件,而是传几千个小文件。如果循环里对每个文件await CopyToAsync,网络传输是串行的,速度上不去。但如果放开并行,每个文件一个Task,几千个文件瞬间产生几千个线程竞争磁盘IO,反而会把磁盘搞成瓶颈,而且内存里的缓冲区也会快速膨胀。
我的做法是引入一个简单信号量,限制并发写入数量:
var semaphore = new SemaphoreSlim(10); // 允许10个并发写入 var tasks = new List<Task>(); for (var i = 0; i < files.Count; i++) { var index = i; tasks.Add(Task.Run(async () => { await semaphore.WaitAsync(); try { // 保存单个文件 } finally { semaphore.Release(); } })); } await Task.WhenAll(tasks);并发数 10 到 20 是个经验值,不需要开太大。SSD 上并发太高反而出现写放大和锁竞争。另外,如果你不想引入Task.Run带来的线程池切换开销,也可以改成用Parallel.ForEachAsync,它是 .NET 6 之后官方的异步并行方法,自带MaxDegreeOfParallelism参数,代码更简洁:
var semaphore = new SemaphoreSlim(10); await Parallel.ForEachAsync(Enumerable.Range(0, files.Count), new ParallelOptions { MaxDegreeOfParallelism = 10 }, async (i, ct) => { // 保存单个文件 });不过要注意,Parallel.ForEachAsync在处理需要保持顺序的场景时要小心,这里每个文件路径是独立的,顺序无关,所以很适合。
4. 面对大文件夹的进阶方案:Zip、分片与进度
4.1 中小目录的终极大招:前端压缩成Zip再上传
如果上传的目录里小文件特别多,比如一个 node_modules 目录几万个文件,老老实实让每个文件单独进 FormData,会有两个问题:请求体的 multipart 头信息占用很大,服务器解析大量小字段本身就有开销;文件传输的网络包效率低。这种情况下,把整个目录前端压缩成一个 Zip 再上传,效率会高一个量级。
前端用 JSZip 可以这样处理:
const zip = new JSZip(); for (const file of files) { zip.file(file.webkitRelativePath, file); } const blob = await zip.generateAsync({ type: 'blob' }); const formData = new FormData(); formData.append('zip', blob, 'folder.zip');JSZip 支持直接把File对象加入压缩包,它会异步读取文件内容,内部全部缓冲在内存里。所以这个方案只适合中小目录,我个人的经验阈值是 50MB 以内比较安全,超过这个值浏览器内存会肉眼可见地上涨。如果你做的是客户端工具而不是网页,可以把压缩这一步放到本地进程里做,阈值可以放大到几百MB。
后端收到 Zip 之后,不建议用ZipFile.ExtractToDirectory一把梭,因为解压过程可能被攻击者塞进来一个路径穿越条目。更稳妥的方式是用ZipArchive逐条读取并校验路径:
[HttpPost("upload-zip")] public async Task<IActionResult> UploadZip(IFormFile zip) { var root = Path.GetFullPath(_options.UploadRootDirectory); Directory.CreateDirectory(root); await using var zipStream = zip.OpenReadStream(); using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read); long totalSize = 0; foreach (var entry in archive.Entries) { // 防止 Zip 炸弹:限制解压总大小 totalSize += entry.Length; if (totalSize > _options.MaxExtractSize) { return BadRequest(new { message = "解压后总大小超出限制" }); } var fullPath = Path.GetFullPath(Path.Combine(root, entry.FullName)); if (!fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase)) { return BadRequest(new { message = $"非法路径: {entry.FullName}" }); } var dir = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); await using var entryStream = entry.Open(); await using var output = new FileStream(EnsureUniquePath(fullPath), FileMode.Create, FileAccess.Write); await entryStream.CopyToAsync(output); } return Ok(new { count = archive.Entries.Count }); }这个方案的缺点也很明显:它要求前端先把整个目录压缩好,如果目录太大,浏览器会卡顿甚至崩溃,所以它和 FormData 直传方案是互补关系,不是替代关系。
4.2 大文件的破局方式:分片上传与断点续传
当文件夹里存在单个超大文件(比如 1GB 以上的视频),或者总大小超过 1GB,单请求上传很容易因为代理超时、网络闪断而失败,而且失败之后前面传的全部要重来。这时候要考虑分片上传。
分片的核心思路是后端提供一个上传会话,前端把文件切成若干片,逐片提交,最后合并。接口一般设计成三个:
POST /api/upload/init:前端提交文件名、相对路径、文件大小、分片大小,后端生成一个uploadId;POST /api/upload/chunk:前端提交uploadId、分片序号、分片内容,后端把分片存到临时目录;POST /api/upload/complete:前端提交uploadId,后端把所有分片按顺序合并成完整文件。
前端切片代码的核心逻辑:
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB 一片 for (const file of files) { const totalChunks = Math.ceil(file.size / CHUNK_SIZE); for (let i = 0; i < totalChunks; i++) { const blob = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE); const formData = new FormData(); formData.append('uploadId', uploadId); formData.append('relativePath', relativePath); formData.append('chunkIndex', i); formData.append('totalChunks', totalChunks); formData.append('chunk', blob); await fetch('/api/upload/chunk', { method: 'POST', body: formData }); } }后端在complete阶段做合并:
[HttpPost("complete")] public async Task<IActionResult> Complete([FromForm] string uploadId, [FromForm] string relativePath) { var tempDir = Path.Combine(_options.TempRoot, uploadId); var finalPath = Path.Combine(_options.UploadRootDirectory, relativePath); // 按分片序号排序后依次追加写入 var chunkFiles = Directory.GetFiles(tempDir) .OrderBy(p => int.Parse(Path.GetFileNameWithoutExtension(p))) .ToList(); await using var output = new FileStream(finalPath, FileMode.Create, FileAccess.Write); foreach (var chunkFile in chunkFiles) { await using var input = new FileStream(chunkFile, FileMode.Open, FileAccess.Read); await input.CopyToAsync(output); } Directory.Delete(tempDir, true); return Ok(); }断点续传的实现有好几种做法。最简单的是把已上传的分片序号记录在 localStorage 里,页面刷新后读取并跳过这些分片。更可靠的是在init阶段让后端返回已经存在的分片列表,前端根据这个列表决定跳过哪些分片。后者的好处是换设备、换浏览器也能续传,因为服务端临时目录里已经有数据了。
分片大小选多少合适?太小的分片会增加请求次数和服务器IO压力,太大的分片失去断点续传的意义。我见过很多项目用 2MB、5MB、10MB,都能跑。如果网络质量差,建议选 2MB;如果是内网或者带宽很高,可以选 10MB。服务器端要注意临时目录的空间清理,否则大量上传任务失败后,临时分片文件会一直残留在磁盘上。
4.3 进度反馈:三种方式让用户不再以为页面挂了
大目录上传最影响体验的是没有反馈。至少要有三种进度信息:
第一是网络传输进度,也就是前端xhr.upload.onprogress这个事件,它告诉我们“浏览器发出了多少数据”。第二是后端处理进度,比如 Zip 解压到第几个文件、分片合并到多少了,这个前端拿不到,需要服务端主动推送。第三是整体完成状态,比如总文件数、成功数、失败数,通常在上传完成后展示。
后端处理进度推送我用 SignalR 实现。在前端上传接口里拿到uploadId之后,让浏览器加入对应的 SignalR 组,后端在处理完每个文件后向组内推送进度:
public class UploadHub : Hub { public async Task JoinGroup(string uploadId) { await Groups.AddToGroupAsync(Context.ConnectionId, uploadId); } public async Task NotifyProgress(string uploadId, int processed, int total) { await Clients.Group(uploadId).SendAsync("onProgress", processed, total); } }如果你不想引入 SignalR,也可以做一个简单的轮询接口,前端每 2 秒请求一次GET /api/upload/progress?uploadId=xxx,后端从内存里的一个ConcurrentDictionary<string, UploadProgress>读取进度返回。轮询的缺点是实时性差一点,但胜在实现门槛低,对项目改动小。
5. 服务器配置与安全边界:别让上传接口成为漏洞
5.1 路径穿越防护:每一层都不能省
文件夹上传天然涉及路径拼接,一旦防御不到位,攻击者可以通过构造../../之类的相对路径让文件写到服务器任意目录。我在BuildSafePath和 Zip 解压代码里都做了Path.GetFullPath之后调用StartsWith(root)的判断,这一步绝对不能省。
路径穿越的攻击手法不止..一种,还有绝对路径,比如/etc/passwd,在 Windows 下是C:\Windows\。Path.GetFullPath(Path.Combine(root, relativePath))会把这类路径解析成独立的绝对路径,再和root比较时无法通过StartsWith,所以能一并拦截。另外要注意大小写问题,Windows 路径不区分大小写,判断时要加StringComparison.OrdinalIgnoreCase。
前端传过来的路径虽然是浏览器生成的webkitRelativePath,看起来“安全”,但不能信任。攻击者完全可以绕过前端直接构造 HTTP 请求,所以服务端必须以不可信数据来对待。
5.2 调整Kestrel与IIS的请求体限制
ASP.NET Core 默认的请求体限制非常保守,通常在 30MB 到 128MB 这个量级,不做配置的话传个大文件直接 413。这个配置有三个地方要改,缺一不可:
第一是 Kestrel 的限制,需要在Program.cs里配置:
builder.WebHost.ConfigureKestrel(options => { options.Limits.MaxRequestBodySize = 2L * 1024 * 1024 * 1024; // 2GB });第二是表单相关的限制:
builder.Services.Configure<FormOptions>(options => { options.MultipartBodyLengthLimit = 2L * 1024 * 1024 * 1024; options.ValueLengthLimit = 1024 * 1024; // 单个表单值长度,路径JSON可能比较大 options.MultipartHeadersLengthLimit = int.MaxValue; });ValueLengthLimit是很多人忽略的坑。当目录里文件数量上万个时,pathsJson这个字符串可能达到几 MB,如果ValueLengthLimit还是默认值,后端直接报 400。我调这个配置之前,遇到过前端选择了一个大目录,传上去后端立刻报“The field must be a string with a maximum length of X”,排查半天才定位到是这个限制。
第三是 IIS 的配置,如果你的应用部署在 IIS 后面,光调 Kestrel 没用,IIS 也会拦一道。需要加web.config:
<configuration> <system.webServer> <security> <requestFiltering> <requestLimits maxAllowedContentLength="2147483648" /> </requestFiltering> </security> </system.webServer> </configuration>如果是传统 ASP.NET(非 Core),还要在system.web节点配置:
<system.web> <httpRuntime maxRequestLength="2097152" executionTimeout="3600" /> </system.web>这些配置必须以字节为单位,不能写成2GB。maxRequestLength单位是 KB,maxAllowedContentLength单位是字节,转换错了结果差一千倍。
5.3 二道校验:文件类型、Zip炸弹与磁盘空间
请求体限制解决的是“能不能接收”,文件内容校验解决的是“接收之后安不安全”。我见过很多上传功能只校验扩展名,攻击者改个后缀就绕过。实际项目里至少要做一个 magic number 校验,也就是读文件头部几个字节判断真实类型。比如判断 PNG 文件头是否以89 50 4E 47开头,Zip 文件是否有PK头。这个不是绝对安全,但能把大部分伪装文件拦在门外。
Zip 上传有额外风险,即“Zip炸弹”。一个几十 MB 的压缩包解压后可能膨胀到几个 GB。前面解压代码里对totalSize做了限制,这就是防 Zip 炸弹。更严格一点,可以限制解压后总大小与压缩大小的比例,比如超过 100 倍直接拒绝。
磁盘空间检查也值得加。文件夹上传往往发生在服务器磁盘快满的时候,多几千个大文件可能直接把磁盘写满,导致数据库写不进去、日志写不了,整个服务异常。在上传前用DriveInfo.GetAvailableFreeSpace检查一下剩余空间是否超过本次上传的总大小,不够就提前返回明确的错误信息,而不是让用户传到一半才失败。
6. 常见问题排查与落地建议
6.1 高频问题速查表
我整理了一份在实际项目中反复遇到的问题表,格式按“现象 / 原因 / 解决办法”三列展开:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 上传大文件报 413 | Kestrel 或 IIS 请求体限制未调大 | 同时配置MaxRequestBodySize和maxAllowedContentLength |
| 后端收到文件但目录结构全丢了 | 前端没有传相对路径,或者后端只用了FileName | 前端将webkitRelativePath单独序列化提交,后端按相对路径建目录 |
| 同名文件互相覆盖 | 保存时只用了文件名,没有考虑相对路径和重名场景 | 用完整相对路径写入,并调用EnsureUniquePath处理重名 |
| 中文文件名乱码 | Zip 包内编码不是 UTF-8 | 解压时检测编码,必要时使用支持 GBK 的压缩库 |
| 请求被 ASP.NET Core 防伪校验拦截 | XHR 请求没带防伪令牌 | 设置RequestVerificationToken请求头 |
| 前端选中目录后页面卡死 | 文件数量过多,一次性构建超大 FormData 或前端压缩内存溢出 | 按总大小分流:中小目录直接传,大目录走分片 |
| 上传完成后服务器磁盘空间不足 | 没有在接收前检查磁盘剩余空间 | 上传前检查目录剩余空间,不够则提前拒绝 |
| 解压上传的Zip时文件写到了根目录之外 | Zip条目包含../路径 | 逐条目校验Path.GetFullPath是否以目标根目录开头 |
6.2 容易被忽视的隐蔽坑
第一个坑是Path.Combine遇到第二个参数是绝对路径时的行为。假如relativePath是C:\Windows\system32\a.dll,在 Windows 上Path.Combine(root, relativePath)的结果不是root下面的路径,而是直接返回C:\Windows\system32\a.dll。这也是为什么上面代码里必须先做Path.GetFullPath再做StartsWith(root)校验,顺序不能乱。
第二个坑是目录分隔符的统一。前端webkitRelativePath永远是/,但Path.Combine在 Windows 上用的是\。如果你不做任何转换,Path.Combine能识别/并用它拼接,但Path.GetDirectoryName和Directory.CreateDirectory在某些边界情况下会行为不一致。我习惯在BuildSafePath里先把/和\统一替换成Path.DirectorySeparatorChar,这一步能避免后续很多诡异问题。
第三个坑是空文件夹。我用input.webkitdirectory选目录时,空子目录不会出现在files列表里。如果业务要求保留空目录结构,前端要把目录清单和文件清单一起传给后端,后端先创建目录再写文件。这个需求很少见,但遇到了会很头疼,提前设计进接口里是最稳妥的。
6.3 按场景选择上传方案的落地建议
最后给一个可以直接参考的选择逻辑:
- 目录总大小小于 50MB,且文件数量不多(几百个以内):前端 FormData 直传,每个文件带相对路径,后端流式写盘,最简单。
- 文件数量非常多但单个文件很小(比如源码目录、静态资源目录):前端 JSZip 压缩后上传,后端解压重建目录,文件数量越多收益越明显。
- 单个文件超过 100MB 或者目录总大小超过 1GB:分片上传加断点续传,前端记录每个文件的
uploadId和分片状态。 - 对进度有强烈要求:XHR 进度条展示传输百分比,SignalR 推送后端处理进度,两个配合起来体验最好。
我个人做这个功能时最深的体会是,文件夹上传真正不能省的功夫都在边界上:路径穿越校验、编码处理、重名策略、请求限制配置、临时目录清理。把这些边界问题处理完,核心的“上传文件”本身反而是最简单的一步。后面如果你要扩展,可以在上传结果里增加数据库记录,把文件清单、相对路径、大小、哈希值落到一张表里,这样文件系统只是存储层,你随时可以在业务层做去重、版本管理和垃圾清理。