1. 文件夹上传为什么不是"多选文件"那么简单
我上个月接了一个内部资料库系统的改造,需求描述只有一句话:用户能一次选中整个文件夹传到服务器,并且服务器上保留原来的目录层级。前端是普通网页,后端是 asp.net。当时觉得这不就是把 input 的 multiple 打开嘛,结果一上手才发现,文件夹上传这件事,藏着三个层面的问题:浏览器能不能拿到目录信息、前端怎么把目录树结构传出去、asp.net 后端怎么按结构还原文件。三块全是活,缺一块整个功能就是残废。
先说我最初想偷懒的方案。很多人在第一版会写<input type="file" multiple>,让用户进文件夹里全选文件。这能实现"多文件上传",但服务器最终拿到的是一堆平铺文件,目录层级全部丢失。比如用户在本地有个项目文档/2024/技术方案/接口文档.docx,传到服务器后只剩接口文档.docx。如果资料库里恰好有大量文件是不同目录下同名文件,后传的直接覆盖先传的,数据就毁了。所以文件夹上传和多选文件是两回事,核心差异在"目录结构"这四个字。
1.1 浏览器对文件夹的"隔离"逻辑
浏览器出于安全和隐私考虑,对本地文件系统做了非常严格的隔离。网页里的 JavaScript 无法直接读取用户磁盘上的任意路径,只能通过<input type="file">或拖拽获取File对象。而这个File对象虽然带 name、size、type、lastModified 这些元数据,却唯独不告诉你文件在磁盘上的完整路径。
看一下浏览器给你暴露的信息:
文件名:接口文档.docx(只暴露文件名) 大小:245KB 类型:application/vnd.openxmlformats-officedocument.wordprocessingml.document 最后修改时间:2024-03-15 10:32:18 路径:未知(没有这个字段)传统多选文件模式下,这就是极限了。前端拿不到路径,后端自然也无从还原目录结构。想让目录信息从浏览器里"合法"流出来,必须走专门的文件夹选择 API,这就是后面要重点说的webkitdirectory和webkitRelativePath。
1.2 三条可行的实现路线对比
我实际调研过三条实现路线,先列个表格,后面会分别展开讲:
| 方案 | 是否保留目录结构 | 浏览器兼容性 | 代码复杂度 | 适用场景 |
|---|---|---|---|---|
原生input+webkitdirectory | 支持,读取webkitRelativePath | 主流现代浏览器均支持,移动端部分浏览器受限 | 低,原生API,无需依赖 | 大多数后台管理系统首选 |
拖拽上传 +webkitGetAsEntry() | 支持,可识别空目录 | 桌面端主流浏览器支持,移动端拖拽本身受限 | 中,需要递归遍历目录树 | 需要拖拽交互、需要保留空文件夹的场景 |
| 第三方上传组件 | 取决于组件实现,多数仅支持平铺文件 | 取决于组件底层实现 | 低,封装完整 | 快速原型验证 |
最终我选了方案一作为主流程,方案二作为拖拽补充。原因很简单:方案一代码量最小,浏览器支持面足够广,稳定性最好;方案二能拿到空目录信息,这是方案一做不到的,但拖拽交互在后台系统里不是刚需,所以放到二期再做也不迟。
2. 前端目录解析:从 webkitRelativePath 到目录树
文件夹上传的关键一步,是把用户选中的文件夹变成一棵前端可以操作的目录树。这一步做扎实了,后面传什么、后端怎么还原,都只是搬运问题。
2.1 一个属性让 input 变文件夹选择器
原生 input 加两个属性就能支持文件夹选择:
<input type="file" id="folderPicker" webkitdirectory directory multiple />其中webkitdirectory是核心指令,告诉浏览器打开的是文件夹选择对话框而不是文件选择对话框;directory是标准属性名,目前支持度还没有webkitdirectory广,所以两者都写上做兼容;multiple是为了保险,保证能多选。在浏览器里实际效果是:用户点击 input 后,弹出的是文件夹选择窗口,选中整个文件夹会触发 change 事件。
然后监听 change 事件:
const folderInput = document.getElementById('folderPicker'); folderInput.addEventListener('change', (event) => { const files = Array.from(event.target.files); if (files.length === 0) return; // 每个文件都带一个 webkitRelativePath,这是关键 console.log(files[0].webkitRelativePath); // 例如:项目文档/2024/技术方案/接口文档.docx });注意,event.target.files返回的是一个FileList,它把文件夹里的所有文件平铺展开成了一个一维数组,并没有直接给你目录层级。但每个File对象上挂了一个webkitRelativePath属性,值是文件的相对路径,以/分隔。这正是整个文件夹上传方案的数据源头。
2.2 把一维 FileList 还原成目录树
拿到了相对路径后,我要做的就是把项目文档/2024/技术方案/接口文档.docx这样的字符串解析出来,构建一棵树。结构我定义成下面这样:
const fileTree = { name: '项目文档', path: '项目文档', isDirectory: true, children: [] };解析的核心思路是按/拆路径,一层一层往下找父节点,找不到就新建。我写了下面这段代码:
function buildDirectoryTree(fileList) { const root = { name: '根目录', path: '', isDirectory: true, children: [] }; // 用 Map 缓存路径和节点的对应关系,避免每次从头查找 const nodeMap = new Map(); nodeMap.set('', root); fileList.forEach((file) => { const relativePath = file.webkitRelativePath || file.name; const parts = relativePath.split('/'); // parts 最后一个是文件名,前面都是目录名 let currentPath = ''; for (let i = 0; i < parts.length - 1; i++) { const dirName = parts[i]; currentPath = currentPath ? `${currentPath}/${dirName}` : dirName; if (!nodeMap.has(currentPath)) { const dirNode = { name: dirName, path: currentPath, isDirectory: true, children: [] }; nodeMap.set(currentPath, dirNode); // 把当前目录挂到父节点上 const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/')); const parentNode = nodeMap.get(parentPath); parentNode.children.push(dirNode); } } // 最后挂文件 const fileName = parts[parts.length - 1]; const filePath = currentPath ? `${currentPath}/${fileName}` : fileName; const fileNode = { name: fileName, path: filePath, isDirectory: false, size: file.size, lastModified: file.lastModified, file: file // 关键,后面上传要用 }; const parentNode = nodeMap.get(currentPath); parentNode.children.push(fileNode); }); return root; }这样前端就拿到了一棵和用户本地目录完全对应的树。上面代码里有一个值得说的优化点:我用Map缓存了"路径字符串 → 节点对象"的映射。如果不加缓存,每次解析新路径都要从根节点递归查找,文件夹大了以后(比如有几千个文件、几十层目录),运行开销会比较明显,实测下来的差距可能达到几十倍。
2.3 一个容易被忽略的边界:空文件夹
使用webkitdirectory路径有一个天然限制:FileList里只包含文件,空的文件夹根本不会出现。如果用户的文件夹里有个图片存档/目录,里面是空的,那么webkitRelativePath里永远不会有图片存档/这个入口,这个空目录就会在上传后静默消失。
我第一次测试时没注意到这个细节,直到用户反馈"我建的分类目录怎么传上去就没了",才意识到这是 API 层面的短板。如果需求里明确要求保留空目录,那就要放弃纯webkitdirectory方案,改用拖拽 +webkitGetAsEntry():
function traverseEntry(entry, parentNode) { if (entry.isFile) { entry.file((file) => { // 构建文件节点,file 对象和路径都拿到了 }); } else if (entry.isDirectory) { // 这是一个目录节点,即使里面为空,也会以 entry 的形式存在 const dirNode = { name: entry.name, isDirectory: true, children: [] }; parentNode.children.push(dirNode); const reader = entry.createReader(); // reader 一次最多返回 100 个条目,需要循环读取直到返回空数组 const readAllEntries = () => { reader.readEntries((entries) => { if (entries.length === 0) return; entries.forEach((childEntry) => traverseEntry(childEntry, dirNode)); readAllEntries(); // 继续读下一批 }); }; readAllEntries(); } }这段代码里有几个坑值得单独说。第一,createReader()创建的 reader 是一次性的,多次调用readEntries()需要用同一个 reader;第二,readEntries()每次最多返回 100 条,读完后必须再次调用,直到返回空数组,否则深层目录读不全;第三,回调式的entry.file()拿到 File 是异步的,如果你要构建完整树,得做好异步流程控制,我实际用了Promise包装才避免回调地狱。这个方案能识别空目录,但复杂度比方案一高不少,所以我当时把它定位成"拖拽增强"而不是主流程。
3. asp.net 后端如何接收和还原目录结构
前端把目录树构建出来以后,后端的接收逻辑就成了重头。这块我踩过的坑最多,也最值得展开说。
3.1 传输协议:目录结构元数据与文件内容分离
第一次实现时,我试图把整棵树序列化成 JSON,用 multipart/form-data 一次性提交,里面既包含目录结构又包含文件内容。文件夹小的时候没问题,文件一多就翻车了——前端把所有文件读进内存再组装 FormData,浏览器直接卡死,内存占用轻松超过 1GB。
后来我调整了协议设计,核心思路是让文件内容走 multipart 分片,目录结构元数据走 JSON 接口,两者通过一个 uploadId 关联。具体流程是这样的:
- 前端拿到目录树后,先调用
POST /api/upload/initialize,把目录树 JSON 发给后端,同时获取一个 uploadId。 - 后端把目录结构暂存在数据库或缓存里,返回 uploadId。
- 前端再逐个(或并发)读取文件,调用
POST /api/upload/file/{uploadId}上传文件内容,每个文件都附带自己的相对路径。 - 全部文件上传完成后,前端调用
POST /api/upload/complete/{uploadId},后端核对文件数量后,一次性把暂存文件移动到正式目录,完成还原。
这个设计的优势是:目录结构传输是轻量级的,不会因为文件太大而阻塞;文件内容分片传输,单请求体积可控;任何一步失败,上传流程都可以从断点恢复,而不用整个重来。
3.2 C# 模型定义与反序列化
前端目录树 JSON 的结构大概是这样的:
{ "uploadId": "0f8fad5b-d9cb-469f-a165-70867728950e", "root": { "name": "项目文档", "isDirectory": true, "children": [ { "name": "2024", "isDirectory": true, "children": [ { "name": "接口文档.docx", "isDirectory": false, "size": 245760 } ] } ] } }后端对应的 C# 模型:
public class UploadDirectoryDto { public string Name { get; set; } public bool IsDirectory { get; set; } public long? Size { get; set; } public List<UploadDirectoryDto> Children { get; set; } } public class UploadInitializeRequest { public UploadDirectoryDto Root { get; set; } }接收初始化接口:
[HttpPost("initialize")] public async Task<IActionResult> Initialize([FromBody] UploadInitializeRequest request) { var uploadId = Guid.NewGuid().ToString("N"); // 把目录树存入缓存,后续文件上传时按 uploadId 取 await _uploadCache.SetAsync(uploadId, request.Root, TimeSpan.FromHours(4)); return Ok(new { uploadId }); }这里我用缓存暂存目录树,而不是直接落库,是因为文件还没传完,正式目录不应该出现"半成品"。等 complete 接口调用时,再从缓存取目录树,校验文件齐了,才真正开始还原。
3.3 服务端目录还原:递归创建与安全校验
真正的还原逻辑是在 complete 接口里做的。核心是一个递归函数,遍历目录树,在服务器上创建目录、移动文件:
[HttpPost("complete/{uploadId}")] public async Task<IActionResult> Complete(string uploadId) { var directoryTree = await _uploadCache.GetAsync<UploadDirectoryDto>(uploadId); var tempRoot = Path.Combine(_tempBasePath, uploadId); var targetRoot = Path.Combine(_libraryRootPath, "用户上传"); await RestoreDirectory(directoryTree, tempRoot, targetRoot); // 清理临时目录 Directory.Delete(tempRoot, true); return Ok(new { success = true }); } private async Task RestoreDirectory( UploadDirectoryDto node, string tempPath, string targetPath) { // 安全第一:目标路径必须位于根目录之下,防止路径穿越 var fullTargetPath = Path.GetFullPath(targetPath); var fullRootPath = Path.GetFullPath(_libraryRootPath); if (!fullTargetPath.StartsWith(fullRootPath, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("非法路径"); } if (node.IsDirectory) { Directory.CreateDirectory(fullTargetPath); foreach (var child in node.Children) { var childTargetPath = Path.Combine(fullTargetPath, SanitizeFileName(child.Name)); var childTempPath = Path.Combine(tempPath, SanitizeFileName(child.Name)); await RestoreDirectory(child, childTempPath, childTargetPath); } } else { // 文件节点:从临时目录移动到目标位置 var sourceFile = Path.Combine(tempPath, SanitizeFileName(node.Name)); var destFile = Path.Combine(fullTargetPath, SanitizeFileName(node.Name)); Directory.CreateDirectory(fullTargetPath); if (File.Exists(sourceFile)) { File.Move(sourceFile, destFile, overwrite: false); } } } private string SanitizeFileName(string name) { var invalidChars = Path.GetInvalidFileNameChars(); var sanitized = new string(name.Where(c => !invalidChars.Contains(c)).ToArray()); return string.IsNullOrWhiteSpace(sanitized) ? "未命名文件" + Guid.NewGuid().ToString("N") : sanitized; }这段代码里有几个关键点:
其一,Path.GetFullPath校验是必须的。如果直接拿前端传来的相对路径拼到根目录后面,恶意客户端完全可以传一个../../Windows/system32这种路径,把文件写到不该写的地方。路径穿越漏洞就是这么来的。正确的做法是先拼完整路径,再用StartsWith(根路径)判断,确保最终路径一定在允许的范围内。
其二,SanitizeFileName用来过滤文件名里的非法字符。Windows 下\ / : * ? " < > |都不能出现在文件名里,而用户本地文件名完全可能包含这些字符(Linux 下合法的文件名在 Windows 下不一定合法),所以必须清洗,否则Directory.CreateDirectory会直接抛ArgumentException。
其三,文件移动用的是File.Move而不是File.Copy,因为 Move 在同一磁盘分区上是重命名操作,速度极快,还省一次磁盘写。这就是为什么我设计成"先传临时目录,complete 时移动"的原因。如果你的存储是分布式文件系统,移动和复制没有性能差异,那就无所谓了。
3.4 同名文件的处理策略
目录还原还有一个躲不开的问题:用户上传的文件夹里,某个层级可能出现同名文件。比如报告.docx和报告(1).docx这种在 Windows 上自动生成的副本,或者两个不同文件恰好同名。
我定的策略是:如果目标位置已经存在同名文件,自动重命名,加上(1)、(2)这样的后缀,而不是覆盖。原因很简单,资料库场景下任何覆盖操作都是数据事故,宁可多一个文件也不丢一个文件。实现时就是在移动前检查File.Exists,存在就循环加序号:
private string GenerateUniqueFilePath(string targetDirectory, string fileName) { var candidate = Path.Combine(targetDirectory, fileName); var index = 1; var nameWithoutExt = Path.GetFileNameWithoutExtension(fileName); var ext = Path.GetExtension(fileName); while (File.Exists(candidate)) { candidate = Path.Combine(targetDirectory, $"{nameWithoutExt}({index++}){ext}"); } return candidate; }4. 大文件夹上传:并发、分片与进度反馈
到这里,目录还原的逻辑已经通了。但真拿一个含 2000 个文件、总共 8GB 的文件夹来测,第一版方案直接卡死。问题不在于逻辑,而在于性能设计。大文件夹上传,光有"能传"不够,还得"传得动、传得快、跑不挂"。
4.1 为什么不能把文件一次性塞进 FormData
第一版代码里,我循环遍历目录树把每个文件都append到 FormData,然后一次性XMLHttpRequest提交。文件夹小的时候没事,文件夹大的时候有两个问题:
第一个是内存爆炸。一个 2GB 的文件被读进浏览器内存再塞进 FormData,浏览器进程内存占用立刻飙升,在低配机器上直接页面崩溃。第二个是请求体过大。ASP.NET 默认请求体大小限制是 30MB(IIS 默认maxAllowedContentLength),超过直接给你返回 404.13 状态码。所以大文件上传必须走分片。
分片的逻辑很直接,前端用File.slice()把大文件切成固定大小的块,逐块上传:
const SLICE_SIZE = 5 * 1024 * 1024; // 5MB 一片 async function uploadFileInChunks(file, relativePath, uploadId, onProgress) { const totalChunks = Math.ceil(file.size / SLICE_SIZE); for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { const start = chunkIndex * SLICE_SIZE; const chunk = file.slice(start, start + SLICE_SIZE); const formData = new FormData(); formData.append('uploadId', uploadId); formData.append('relativePath', relativePath); formData.append('chunkIndex', chunkIndex); formData.append('totalChunks', totalChunks); formData.append('file', chunk); await uploadChunk(formData); const loaded = Math.min((chunkIndex + 1) * SLICE_SIZE, file.size); onProgress(loaded, file.size); } } function uploadChunk(formData) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload/file/chunk'); xhr.onload = () => { if (xhr.status === 200) resolve(); else reject(new Error(`上传失败:${xhr.status}`)); }; xhr.onerror = () => reject(new Error('网络错误')); xhr.send(formData); }); }分片大小选用 5MB 是权衡后的结果。片太小(比如 1MB)会导致请求数量剧增,每个请求都有 HTTP 头开销,总吞吐反而不高;片太大(比如 20MB)又容易撞上请求体限制,而且失败重传的代价更大。5MB 在大多数内网和公网场景下都是一个比较稳的参数。
4.2 前端并发控制:不能无脑全开
分片之后,文件的传输变成了一堆独立的请求。如果所有分片同时发,网络连接数会瞬间打满,浏览器会排队,服务器要维护大量并发连接,反而更慢。所以必须做并发控制,我用了最简单的"信号量"模式:
class ConcurrentQueue { constructor(limit) { this.limit = limit; this.activeCount = 0; this.queue = []; } enqueue(task) { return new Promise((resolve, reject) => { this.queue.push({ task, resolve, reject }); this.drain(); }); } drain() { if (this.activeCount >= this.limit) return; const next = this.queue.shift(); if (!next) return; this.activeCount++; next.task() .then(next.resolve) .catch(next.reject) .finally(() => { this.activeCount--; this.drain(); }); } }使用时先拿到目录树里的所有文件节点,把它们的分片任务全部塞进队列,并发数设置成 3 到 5 之间。我在实测中发现,并发数为 1 时大文件上传慢得让人怀疑是网坏了;并发数超过 6 后,收益就开始递减;到 8 以上,反而因服务器连接池排队出现超时。最终我锁定了 4,这个值在千兆内网和公网环境下表现都比较均衡。
4.3 全局进度:不是简单的"已传文件相加"
进度反馈也要设计。文件夹上传涉及多文件,不要逐个文件显示进度条,那样用户根本盯不过来。更合理的做法是算一个全局百分比:
let totalBytes = 0; let uploadedBytes = 0; // 初始化时先遍历目录树,累加所有文件的大小 function calcTotalSize(nodes) { let total = 0; nodes.forEach((node) => { if (node.isDirectory) { total += calcTotalSize(node.children); } else { total += node.size; } }); return total; } // 每个分片上传成功后,更新 uploadedBytes function updateUploadedBytes(chunkSize) { uploadedBytes += chunkSize; const percent = Math.min(100, Math.round((uploadedBytes / totalBytes) * 100)); updateProgressBar(percent); }注意一个细节:uploadedBytes累加的是分片的实际字节数,而不是"文件已传数×文件大小"。因为最后一处分片大小可能不足 5MB,用固定值会算错。另外,进度计算里要清掉已上传的字节,否则重传某片时会把同一段字节算两次,进度条会忽快忽慢甚至超过 100%。
4.4 服务端临时目录:先落盘,后合并
服务端接收分片时,不能直接把分片拼到正式目录,那会留下大量半截文件。正确做法是为每个 uploadId 建一个临时目录,分片先落到这个目录下。所有分片传完后,服务端按chunkIndex序号拼接文件,再移动到正式目录。
C# 端接收分片的关键代码:
[HttpPost("file/chunk")] public async Task<IActionResult> UploadChunk( [FromForm] string uploadId, [FromForm] string relativePath, [FromForm] int chunkIndex, [FromForm] int totalChunks, IFormFile file) { // 安全校验:uploadId 必须符合 GUID 格式,防止路径注入 if (!Guid.TryParse(uploadId, out _)) return BadRequest("非法 uploadId"); // 临时文件按 uploadId/序号 存储 var tempDir = Path.Combine(_tempBasePath, uploadId); Directory.CreateDirectory(tempDir); var chunkFileName = $"{chunkIndex:D6}.part"; var chunkPath = Path.Combine(tempDir, chunkFileName); using (var stream = new FileStream(chunkPath, FileMode.Create)) { await file.CopyToAsync(stream); } return Ok(new { received = true }); }分片全部传完后,前端调用 complete 接口,后端在还原目录树之前,需要先扫描临时目录,按序号把.part文件合并成完整文件:
private async Task MergeChunks(string uploadId, string relativePath, string tempRoot, string targetFile) { var chunkDir = Path.Combine(tempRoot, "chunks"); var chunkFiles = Directory.GetFiles(chunkDir) .Select(f => new { Path = f, Index = int.Parse(Path.GetFileNameWithoutExtension(f)) }) .OrderBy(x => x.Index) .ToList(); using (var output = new FileStream(targetFile, FileMode.Create, FileAccess.Write)) { foreach (var chunk in chunkFiles) { using (var input = new FileStream(chunk.Path, FileMode.Open, FileAccess.Read)) { await input.CopyToAsync(output); } } } }合并时务必按IndexOf排序,我一开始没排序,直接用Directory.GetFiles的顺序拼接,结果大文件拼接后 CRC 校验直接挂掉。文件系统遍历目录时返回顺序不保证是按文件名排序的,这个坑非常隐蔽。
5. 实测中的高频坑与排查思路
整个功能跑通之后,我花了两周时间做各种边角测试,断断续续踩了不少坑。这些坑单看都是小事,但如果你不做压力测试,就只会等用户来报 bug。我把最有价值的几个放在这里,当作"排障清单"用。
5.1 webkitdirectory 的兼容性边界
webkitdirectory这个属性虽然带 webkit 前缀,但实际所有主流浏览器(Chrome、Edge、Firefox、Safari)都支持。不过使用它时,我建议不要裸写 HTML 属性,而是先判断当前浏览器是否支持:
const isFolderUploadSupported = (() => { const input = document.createElement('input'); return 'webkitdirectory' in input; })(); if (!isFolderUploadSupported) { // 降级为普通多文件上传,提示用户手动选择文件夹内的所有文件 }移动端是重灾区。iOS Safari 虽然支持webkitRelativePath,但部分低版本会退化成"只能选单个文件";Android 的 Chrome 在部分国产浏览器内核上也会失效。我的处理策略是:检测到不支持时,前端直接切换成普通多文件上传,并且在后端做一个"路径折叠"逻辑——所有文件统一放进一个名为已上传文件的目录,至少保证用户不丢失文件。
5.2 webkitRelativePath 的分隔符与大小写问题
webkitRelativePath在不同平台返回的分隔符有差异。桌面 Chrome 统一返回/,但某些版本的 Edge 在 Windows 上可能混入\。如果你直接用前端的相对路径去拼服务端路径,很可能遇到Path.Combine生成双重分隔符、或者目录名是\foo这样的怪目录。
正确的做法是:前端把所有相对路径统一转换成/分隔格式,后端的SanitizeFileName再把/替换成路径分隔符。另外,Windows 文件系统不区分大小写,但 Linux 区分。如果你的 asp.net 部署在 Linux 容器里(这很常见),前端传Report.docx和后端已存在的report.docx在 Windows 上视为同名,在 Linux 上则不是。为了跨环境稳定,我统一用小写比较来做重名判断,避免部署环境切换后行为不一致。
5.3 空文件夹再次成为一个"bug"
前面说过webkitdirectory拿不到空目录,我主流程选择接受这个限制。但第二个坑是:即使前端拿到了空目录(通过拖拽 API),后端还原时如果先创建了空目录,中途某个文件上传失败,complete 会抛异常,空目录已经创建了但没进入正式目录,形成僵尸目录。
我的调整方案是:目录树还原开始时,不直接创建目录,而是先扫描所有文件节点,全部移动成功后再创建空目录。顺序变了以后,失败回滚的逻辑简单了很多——文件没移完就不建目录,目录永远不会是半成品。
5.4 请求体大小限制:IIS 与 Kestrel 的双重门槛
如果你把 asp.net 部署在 Windows Server 的 IIS 后面,分片虽然规避了单请求过大问题,但初始化和 complete 接口传的目录树 JSON 依然可能超出限制。一个有几万个文件的文件夹,目录树 JSON 可能膨胀到几 MB,而 IIS 默认maxAllowedContentLength是 30MB,一般够用;但如果你传的是超大文件夹,JSON 完全可能超过 30MB。
另一个限制来自 Kestrel。ASP.NET Core 的 Kestrel 默认请求体大小限制是 30MB,可以通过代码调整:
builder.WebHost.ConfigureKestrel(options => { options.Limits.MaxRequestBodySize = 100 * 1024 * 1024; // 100MB });IIS 侧的web.config也要同步放开:
<system.webServer> <security> <requestFiltering> <requestLimits maxAllowedContentLength="104857600" /> </requestFiltering> </security> </system.webServer>这里有个很容易被忽略的地方:IIS 的maxAllowedContentLength单位是字节,默认值是 30000000(约 30MB),而 Classic ASP.NET 的maxRequestLength单位是 KB,默认是 4096(4MB),两者是分开配置的。只调一个不调另一个,请求还是会挂。如果你用的是 ASP.NET Core 托管在 IIS 上,这两个值都需要确认。
排查这类问题时,最直接的信号是响应状态码:404.13表示请求内容长度超限,413可能来自 Kestrel,400则多半是模型绑定失败。看到这些状态码,先去查配置,而不是怀疑代码逻辑,能省很多时间。
整套方案跑下来,我的体会是:文件夹上传没有想象中那么神秘,本质是"前端目录树解析 + 后端安全还原 + 分片并发控制"三个部件组装。最难的不是某个具体 API 怎么用,而是把这三个部件的边界划清楚——谁负责结构、谁负责内容、谁负责进度,边界清楚了,后面每个环节都是独立的可测试单元。如果让我再选一次,我会在需求阶段就确认"是否保留空文件夹"和"是否有超大文件夹场景",这两个问题决定了前端走哪条方案、后端要不要做分片。这个确认动作,比后面任何一行代码都值钱。