干过工程建筑类信息系统的朋友,应该都有过被图纸和模型折磨的经历。设计院交付一套施工图,按专业分建筑、结构、机电,每个专业下面再按楼栋、楼层、图号继续分,一位资料员一次性要归档的东西,轻则几百兆,重则几十个G。.NET MVC 原生的文件上传,说白了就是单个<input type="file">,一次一个文件,页面上传完再把目录结构手动建一遍,这套流程在单体项目里或许够用,放到一个真实的项目管理系统里,基本等于原地爆炸。这篇文章不是讲理论,而是把我做 .NET MVC 大文件夹上传模块时踩过的坑、验证过能跑生产环境的做法,完整梳理一遍。如果你是做工程管理、图档管理、协同平台这类系统的,而且正在被“大文件夹上传”折磨,这篇应该能帮你少走不少弯路。
1. 先搞清楚“文件夹上传”在建筑行业到底是什么需求
很多开发拿到需求就急着找组件,结果做出来一个“能选文件夹、能传文件”的玩具,放到项目现场被资料员骂到自闭。问题出在需求理解上。工程建筑行业的文件夹上传,和普通办公系统里传几个附件根本不是一回事,它有非常特殊的行业惯性。
1.1 工程文件的三个硬特征:大、多、密
先说大。工程行业里最典型的文件是CAD图纸、Revit中心模型、PDF排版图、现场无人机航拍视频。一个中型项目的Revit中心模型,随随便便就奔着10GB以上去;一套完整的竣工图,按单栋楼算也是几个GB;招投标阶段经常要传几百MB的标书压缩包。这种量级,用传统表单上传加服务器端Request.Files的方式,几乎必死。
再说多。一个项目的资料归档,可能有两三万份文件。施工日志按天存,隐蔽工程验收记录按部位存,质量安全整改通知单按责任单位存。文件数量一旦上千,人工逐文件上传完全不可接受,必须允许用户直接拖入整个文件夹,比如把 “项目一号楼/结构/节点图/” 整个拖进去。
然后是密,也就是目录层级。工程资料的目录结构是有严格章法的,不是随便起个文件夹就完事。常见的是“项目编号/标段/专业/单位工程/分部工程/文件类型/文件”,例如:
XM-2024-003/一期地下室/机电安装/给排水/隐蔽验收记录/地下一层给水管道隐蔽验收记录.docx这类路径一旦被拍平,变成随机文件名丢到一个大目录里,后续归档、检索、版本追溯全部乱套。所以目录结构本身就是业务数据,不是UI装饰。
1.2 目录结构不是 UI 细节,而是归档主键
做工程档案系统的都知道,档案的“档号”规则里,目录层级和文件名是有强约束的。资料员归档时,必须能从这个目录树上直接看到:这个项目有哪些楼栋、每栋楼有哪些专业、每个专业有哪些图纸。也就是说,上传系统必须做到“所见即所得”:用户在本地资源管理器里看到的文件夹树,上传完成后在系统里看到的是同一棵树。
这里有个容易被忽略的点:目录树还要支持增量更新。比如施工单位上传完结构图,一周后又更新了几张节点图,用户希望把新文件拖进同一个项目目录,系统能识别哪些文件已经存在、哪些是新增、哪些是新版本,而不是把整个目录再传一遍。这个需求直接影响后端的存储结构设计,不能只做一个“文件堆积桶”,必须按相对路径落盘、按相对路径建库。
1.3 功能需求清单先列出来
动手写代码之前,先把需求列成表,避免做到一半发现缺这个缺那个:
| 需求维度 | 具体要求 |
|---|---|
| 文件大小 | 单文件最大支持 10GB 级别,不做硬编码上限 |
| 文件数量 | 单批次支持上万文件,页面不卡死 |
| 目录结构 | 完整保留相对路径,后端按路径重建目录树 |
| 断点续传 | 网络中断或页面刷新后可继续,不重传已完成分片 |
| 秒传 | 服务器已有相同内容(MD5一致)时直接跳过 |
| 进度反馈 | 文件夹级总进度 + 单文件进度 |
| 并发控制 | 前端同时上传 3~5 个分片即可,避免打垮服务器 |
| 安全性 | 文件名过滤、路径穿越防护、权限校验 |
| 兼容性 | 主力支持 Chrome/Edge,老浏览器给出降级方案 |
列完之后你会发现,市面上大部分现成控件只能覆盖“单个大文件分片上传”这个点,真正的难点在“目录结构的还原”和“批量断点续传”这两件套上。
2. 方案选型:为什么传统上传控件几乎全部阵亡
我在早期项目里用过不少“成熟方案”,后面都被现实教育了。这个环节把旧方案和新方案的取舍讲清楚,能帮你避免在选型上反复折腾。
2.1 传统<input type="file">单文件上传的死穴
最原始的做法是表单整包提交。前端一个表单,enctype="multipart/form-data",用户选一个文件,点提交,服务端用HttpFileCollectionBase把文件整个读进内存或落盘。这种做法有三个死穴:
- 单文件限制:一次只能选一个文件,虽然可以加
multiple属性选多文件,但目录结构全部丢失。 - 请求体无上限时内存爆炸:整包提交时,一个 2GB 的文件会被一次性读入缓冲区,服务器内存分分钟被打爆,IIS 还会直接回收进程。
- 连接超时:大文件传输时间可能超过 IIS 默认的 110 秒超时,连接一断,全部白传。
这还只是技术上扛不住,业务上更是直接失败:用户没法把一个目录树拖进来,你就得让资料员在系统里手动新建几百个文件夹,再把文件一个个传上去。看起来是在做系统,实际上是在惩罚用户。
2.2 ActiveX 与 Flash 上传控件为什么被淘汰
早些年很多图档系统用 ActiveX 控件,配合 IE 浏览器实现文件夹选择和断点续传。ActiveX 能读取本地目录、能拿到完整路径,功能确实强,但它只支持 IE,而且需要管理员权限安装,经常被安全软件当恶意插件杀掉。Flash 方案(如 SWFUpload)也已全面被浏览器禁用。到今天,如果还在考虑这两种路子的,基本是在给系统埋雷,后面浏览器一升级就整个挂掉。
真正靠谱的方向只有一条:基于 HTML5 File API 的自研分片上传,或者选择基于 HTML5 的开源上传组件再深度改造。
2.3 自研还是用开源组件
社区里常见的几款:WebUploader(百度,已停止维护但可用)、Plupload(已归档)、FineUploader(有社区版)、ECharts 家没有上传组件但有个别情况会用到。用这些组件的好处是分片、进度、并发控制这些轮子都有了,缺点是:
- WebUploader 对目录结构的支持不够直接,要自己维护文件路径信息。
- Plupload 性能一般,而且它内部的分片逻辑在超大文件上不够稳。
- 这类组件默认按“文件列表”处理,对“目录树还原”这种需求,需要花不少功夫把
webkitRelativePath和组件的队列模型粘起来。
我的建议是:如果项目时间紧、团队前端能力一般,用 WebUploader 做底层分片,自己接管目录树部分;如果团队能驾驭原生 File API,直接自研一套轻量上传模块,比改别人的 bug 更省心。我最终生产环境用的是自研方案,代码量不算大,但每个环节都自己能控制,排问题容易得多。
2.4 整体运行流程
整个方案在脑子里过一遍是这样的:
- 用户选择或拖入一个文件夹,浏览器通过 HTML5 的
webkitdirectory属性拿到文件列表。 - 每个文件对象带有
webkitRelativePath属性,这就是我们要保留的目录结构信息,例如一期地下室/给排水/隐蔽验收记录/xx.docx。 - 前端把每个文件按固定大小(比如 5MB)切成多个分片,每个分片作为独立请求上传。
- 后端收到分片后,按文件的 MD5 值建临时目录,把分片放进去。
- 所有分片传完,前端发起合并请求,后端按顺序合并分片,并按原始相对路径写入正式存储目录。
- 系统在数据库里建立目录树记录,用户可以马上看到和本地一致的树形结构。
这六个步骤缺一不可。下面把每一步拆开,给出可以抄作业的代码。
3. MVC 后端实现:分片接收、断点续传与目录还原
这一节是硬核部分,前后端代码我都会给,并且会说明哪些地方是必须注意的细节。环境以 ASP.NET Core MVC 为主,但 MVC 5 的关键差异地方我会单独提示。
3.1 前端:读取文件夹并构造文件清单
页面里不需要什么复杂的组件,一个隐藏的 input 就够了。
<input type="file" id="folderPicker" webkitdirectory multiple> <button onclick="document.getElementById('folderPicker').click()">选择项目文件夹</button>选完文件夹之后,监听change事件:
document.getElementById('folderPicker').addEventListener('change', async (e) => { const files = Array.from(e.target.files); const task = { id: Date.now(), files: [], totalSize: 0, uploadedSize: 0 }; for (const file of files) { // 关键:webkitRelativePath 就是目录结构 const relativePath = file.webkitRelativePath || file.name; task.files.push({ name: file.name, relativePath: relativePath.replace(/\\/g, '/'), size: file.size, file: file, md5: null, chunkCount: Math.ceil(file.size / CHUNK_SIZE) }); task.totalSize += file.size; } uploadTask = task; // 交给上传函数 await startUpload(task); });这里最核心的是file.webkitRelativePath。没有这个属性,你拿到的是拍平的文件名列表,目录结构就丢了。Chrome、Edge 都支持这个属性,但如果你要做兼容处理,可以在不支持webkitdirectory的浏览器里降级为单文件多选,不传目录结构。
3.2 前端:分片上传与并发控制
分片大小我一般取 5MB 到 20MB 之间。内网带宽高,分片可以大一点;公网环境建议 5MB,太大容易单请求超时。
const CHUNK_SIZE = 5 * 1024 * 1024; const CONCURRENCY = 4; // 同时最多 4 个上传请求 async function uploadFileWithChunks(task, fileItem) { // 先检查是否已有分片,实现断点续传 const chunkStatus = await getChunkStatus(fileItem.md5, fileItem.chunkCount); const startIndex = chunkStatus.completed || []; for (let index = 0; index < fileItem.chunkCount; index++) { if (startIndex.includes(index)) continue; const chunk = fileItem.file.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE); const form = new FormData(); form.append('file', chunk, fileItem.name); form.append('fileMd5', fileItem.md5); form.append('chunkIndex', index); form.append('totalChunks', fileItem.chunkCount); form.append('relativePath', fileItem.relativePath); await uploadChunk(form); } } async function startUpload(task) { // 先算整个文件列表的 MD5 集合 for (const item of task.files) { item.md5 = await calculateMD5(item.file); } // 并发控制:用队列或简单分片池 const queue = [...task.files]; const workers = Array(CONCURRENCY).fill(0).map(async () => { while (queue.length) { const item = queue.shift(); await uploadFileWithChunks(task, item); } }); await Promise.all(workers); }calculateMD5建议用spark-md5增量计算。工程文件动不动几个G,一次性读进来算 MD5 会让浏览器白屏,所以必须分片读取计算:
async function calculateMD5(file) { const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); const sliceSize = 2 * 1024 * 1024; let offset = 0; while (offset < file.size) { const slice = file.slice(offset, offset + sliceSize); const result = await readAsArrayBuffer(reader, slice); spark.append(result); offset += sliceSize; } return spark.end(); }注意:如果文件超过几百MB,全量 MD5 也要花几十秒到几分钟,可以在文件名旁显示“校验中”,让用户知道系统正在干活,不然会以为页面卡死。
3.3 后端:接收分片与合并,附 MVC 5 差异说明
后端我用的 ASP.NET Core 控制器,一个接口收分片,一个接口合并。先看接收分片的代码:
[HttpPost("api/upload/chunk")] [RequestSizeLimit(int.MaxValue)] public async Task<IActionResult> UploadChunk( IFormFile file, [FromForm] string fileMd5, [FromForm] int chunkIndex, [FromForm] int totalChunks, [FromForm] string relativePath) { if (string.IsNullOrEmpty(fileMd5) || file == null) return BadRequest("missing parameters"); var tempDir = Path.Combine(_uploadConfig.TempRoot, fileMd5); Directory.CreateDirectory(tempDir); var chunkPath = Path.Combine(tempDir, chunkIndex.ToString()); await using (var stream = System.IO.File.Create(chunkPath)) { await file.CopyToAsync(stream); } return Ok(new { success = true, chunkIndex }); }这段逻辑很简单:把每个分片存到temp/{fileMd5}/目录下,文件名就是这个分片的索引号,例如0、1、2。这样天然支持断点续传,因为同一个分片再次上传时,直接覆盖同名文件。
再看合并接口:
[HttpPost("api/upload/merge")] public async Task<IActionResult> MergeChunks( [FromForm] string fileMd5, [FromForm] string fileName, [FromForm] string relativePath, [FromForm] int totalChunks) { var tempDir = Path.Combine(_uploadConfig.TempRoot, fileMd5); if (!Directory.Exists(tempDir)) return NotFound("temp folder not found"); // 1. 校验分片完整性 for (int i = 0; i < totalChunks; i++) { var path = Path.Combine(tempDir, i.ToString()); if (!System.IO.File.Exists(path)) return BadRequest($"chunk {i} missing"); } // 2. 计算最终存储路径,并做路径穿越校验 var safePath = _uploadConfig.FormalRoot + "/" + relativePath; var fullPath = Path.GetFullPath(safePath); var rootFullPath = Path.GetFullPath(_uploadConfig.FormalRoot); if (!fullPath.StartsWith(rootFullPath, StringComparison.OrdinalIgnoreCase)) return BadRequest("invalid path"); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); // 3. 按顺序合并 await using (var output = new FileStream(fullPath, FileMode.Create, FileAccess.Write)) { for (int i = 0; i < totalChunks; i++) { var chunkPath = Path.Combine(tempDir, i.ToString()); await using var input = new FileStream(chunkPath, FileMode.Open, FileAccess.Read); await input.CopyToAsync(output); } } // 4. 清理临时目录 Directory.Delete(tempDir, true); // 5. 写入数据库记录 await _projectFileService.InsertFileRecordAsync(fileMd5, fileName, relativePath); return Ok(new { success = true }); }这里有三个关键点必须强调:
- 路径穿越校验是必须的。
relativePath来自前端,用户完全可以伪造为../../../../Windows之类的内容,不校验就落盘等于把一个目录删除漏洞直接裸奔给攻击者。用Path.GetFullPath和StartsWith的方式做白名单校验是最简单有效的。 - 合并用流式写入,不要
System.IO.File.ReadAllBytes这种一次性读入,否则几个G的分片合并时会内存溢出。 - 分片完整性检查要放到合并前,否则漏了一个分片,合并出来的文件就是损坏的。前端会在断点续传时检查分片状态,但后端也要兜底。
MVC 5(.NET Framework)的同学注意,处理逻辑是一样的,但拿文件从Request.Files["file"]取,类型是HttpPostedFileBase,拷贝用SaveAs或Stream.CopyTo。另外 MVC 5 默认对请求大小有限制,后面配置章节会细说。
3.4 断点续传与秒传的辅助接口
断点续传不能只靠前端判断。最好的方式是前端在开始传一个文件前,先问后端“我的分片传了哪些”,后端扫描临时目录返回已完成的分片索引:
[HttpPost("api/upload/check-chunks")] public IActionResult CheckChunks([FromForm] string fileMd5, [FromForm] int totalChunks) { var tempDir = Path.Combine(_uploadConfig.TempRoot, fileMd5); var completed = new List<int>(); if (Directory.Exists(tempDir)) { for (int i = 0; i < totalChunks; i++) { var path = Path.Combine(tempDir, i.ToString()); if (System.IO.File.Exists(path)) completed.Add(i); } } return Ok(new { completed }); }秒传的逻辑更简单:后端在文件记录表里查一下fileMd5是否已经存在,存在就直接返回“成功”,前端跳过整个文件的上传。工程行业里图纸版本多,同一个文件被不同的人反复上传非常常见,这个功能极大节省带宽。
[HttpPost("api/upload/check-file")] public async Task<IActionResult> CheckFile([FromForm] string fileMd5) { var exists = await _db.ProjectFiles.AnyAsync(f => f.Md5Hash == fileMd5); return Ok(new { exists }); }这里补充一个设计细节:fileMd5建议由前端计算,但如果用户刷新页面导致所有 MD5 要重算,体验很差。可以把 MD5 结果存在前端localStorage里,以“相对路径 + 文件大小 + 修改时间”为 key,下次刷新直接读取,不需要重新计算。
3.5 数据库里的目录树设计
文件落盘后,数据库必须能反推出目录树,否则界面无法展示“和本地一致的文件夹结构”。我用的表结构比较简单:
CREATE TABLE ProjectFile ( Id INT IDENTITY PRIMARY KEY, ProjectId INT NOT NULL, ParentId INT NULL, -- 父目录节点,根目录为 NULL NodeName NVARCHAR(255) NOT NULL, -- 当前目录名或文件名 RelativePath NVARCHAR(1000) NOT NULL, -- 完整相对路径 IsDirectory BIT NOT NULL DEFAULT 0, FileSize BIGINT NULL, Md5Hash VARCHAR(32) NULL, UploadedBy INT NULL, UploadedAt DATETIME NOT NULL DEFAULT GETDATE() );上传完成后,按相对路径逐级创建节点。例如一期地下室/给排水/隐蔽验收记录/xx.docx,先查或创建“一期地下室”,再查或创建“给排水”,再创建“隐蔽验收记录”,最后创建文件节点,每个节点记录RelativePath作为唯一约束。查询目录树时,用RelativePath前缀匹配加上ParentId关联,一次拿到整棵树的子集。
注意一个实际场景:同名目录在前后两次上传里要合并,同名文件则要处理版本问题。我一般的做法是:同目录下文件名相同但 MD5 不同时,自动生成新版本,旧版本保留并标记IsCurrent=0,这样既能避免误覆盖,又能满足资料版本追溯的需求。
4. 配置文件与 IIS 限制:这步错了直接白干
代码写对了,配置不对,一样传不上来。我见过太多项目在“文件上传最大 30MB”的默认限制下测试大文件夹,怎么传怎么挂,最后发现根本不是代码问题。这一节把 .NET 体系和 IIS 的各个限制位置全部列清楚。
4.1 MVC 5 与 .NET Core 的配置差异
如果是传统的 .NET Framework MVC 5,需要修改web.config:
<configuration> <system.web> <!-- maxRequestLength 单位是 KB,这里大约 10GB --> <httpRuntime maxRequestLength="10485760" executionTimeout="3600" /> </system.web> <system.webServer> <security> <requestFiltering> <!-- requestLimits 单位是 Bytes,这里大约 10GB --> <requestLimits maxAllowedContentLength="10737418240" /> </requestFiltering> </security> </system.webServer> </configuration>这两处必须同时设,因为生效层级不同。httpRuntime管的是 ASP.NET 引擎接收的请求体大小,requestFiltering管的是 IIS 层面的请求体大小,只要有一处没放开,大请求就会被拦掉。
如果是 ASP.NET Core MVC(Kestrel 托管),在appsettings.json里配:
{ "Kestrel": { "Limits": { "MaxRequestBodySize": 10737418240 } } }也可以用属性控制器级配置:
[RequestSizeLimit(10L * 1024 * 1024 * 1024)] [HttpPost("api/upload/chunk")] public async Task<IActionResult> UploadChunk(...)但即使配了 Kestrel,如果部署在 IIS 后面,IIS 的maxAllowedContentLength依然生效,所以最终要按部署环境把这个值也调大。
4.2 IIS 层级还有哪些地方会拦
IIS 有两个极易踩坑的点:请求大小限制和请求超时。请求大小刚才说了,命令设置方式如下:
%windir%\system32\inetsrv\appcmd set config "Default Web Site" -section:requestFiltering -requestLimits.maxAllowedContentLength:10737418240 /commit:apphost请求超时默认 110 秒,大文件即使分片,如果堆积了并发请求,也可能整体超过这个时间。设置方式是修改站点的高级设置里的“连接超时”,或者直接在配置里:
<system.webServer> <httpErrors /> <serverRuntime appConcurrentRequestLimit="65535" /> </system.webServer>还需要注意的是,IIS 如果启用了 URL 长度限制,分片接口的relativePath参数较长(一个几层目录的路径很容易超过 100 字符)会被 404 拦掉,但好消息是文件请求走的是 POST + FormData,不是 URL 参数,所以影响不大。真正要注意的是不要把目录结构拼到 URL 查询字符串里。
4.3 磁盘布局与临时文件自动清理
整个上传过程会产生大量临时分片文件,磁盘布局建议固定为:
D:\ProjectFiles\ temp\ 5f3a9b2c...\0 5f3a9b2c...\1 5f3a9b2c...\2 formal\ P20240101\ 一期地下室\ 给排水\ 隐蔽验收记录\ xx.docxtemp目录只管分片,不会有大文件成品,因此可以放在 SSD 上加快合并速度;formal目录放正式文件,容量规划按项目总量来。合并完成后,分片目录必须删除,否则磁盘会被废分片塞满。
但删除要兜底:如果用户传完一半关掉浏览器,那部分分片就变成孤儿文件。我写了一个定时清理任务,每小时扫一次temp目录,删除最后写入时间超过 6 小时的文件。工程文件大,传得慢,清理阈值不能设太短,否则用户隔夜继续传,分片被清掉就麻烦了。
4.4 并发与带宽的取舍
工程行业内部系统通常是千兆内网,后端瓶颈往往在磁盘 IO 而不是带宽。但如果是外网环境,几十个用户同时拖几个G的文件夹进来,服务器网卡和磁盘压力都会很大。我建议从两层控制:
- 前端代理层控制每个用户的分片并发数为 3~5,不要一上来就 10 个请求齐飞。
- 后端可以按用户维度做简单的并发计数,超过 N 个并发上传请求时返回 503,提醒“当前系统繁忙,请稍后重试”。
分片大小也要按环境调整。内网传 10MB 分片很舒服,外网建议 2~5MB,不然一个字节流乱序重传的代价太高。
5. 实操中常见的坑与排查清单
代码和配置都就位之后,上线前一定要把这些坑提前踩一遍。这一节是我在实际项目中收到过工单的合集,每一条都有人真的踩到过。
5.1 中文文件名与路径编码
工程文件名基本全是中文,还有各种特殊字符:一、(、#、&等。如果用 FormData 传文件,文件名一般不会乱码,但relativePath作为普通表单字段时,如果前端没有做encodeURIComponent,服务端拿到的可能已经乱掉。我在前端统一做了 URL 编码,后端解码:
var relativePath = System.Web.HttpUtility.UrlDecode(form["relativePath"]);中文路径还会带来另一个问题:Windows 文件系统的路径总长度限制是 260 个字符。工程目录层级深,再加中文,很容易超限。解决方式要么启用 Windows 长路径支持(注册表LongPathsEnabled=1),要么在后端对路径做压缩映射。最推荐的还是启用长路径,然后代码里避免对路径做无意义的拼接。
5.2 分片顺序与重复提交
合并时按chunkIndex顺序拼接,看起来简单,但前端如果存在并发重试机制,可能出现同一个分片提交两次,后端的FileMode.Create会覆盖旧分片,这没问题。真正的坑是合并时临时目录里还缺几个分片,你就直接开始合并,结果文件静默损坏。所以合并接口必须先数一遍分片数量,再逐个检查存在性,数量和索引对不上就直接报错。
另外要注意 Merge 接口的并发问题:同一文件如果用户在前端点了两次“合并”,后端可能同时开两个 FileStream 写同一个目标文件,导致文件句柄冲突。我加了一把以fileMd5为 key 的异步锁,或者用数据库的唯一约束兜底。
5.3 超大文件的 MD5 计算与秒传策略
工程文件动辄几个G,全量 MD5 在某些环境会计算很久。如果觉得影响体验,可以退一步:秒传改用“文件大小 + 头尾若干字节 + 文件名”组合判断,命中后再做全量 MD5 校验。不要上来就传几十G还要求前端全量算 MD5,用户等不起。
另一个常见问题是:MD5 计算本身很吃内存。spark-md5增量计算时,每次ArrayBuffer追加后都会增长,全程内存占用和文件大小相关。前端计算超大文件 MD5 时,我会分块,每块 2MB,并且用requestIdleCallback让出主线程,避免页面假死。
5.4 浏览器兼容与降级
webkitdirectory是 Chrome 系私有属性,Firefox 支持但标准未完全统一,Safari 部分历史版本有问题。实际业务中,工程公司的办公电脑通常统一装 Chrome 或 Edge,问题不大,但总会有个别用户用老 IE 或老版本的国产浏览器。我的降级方案是:
- 检测
window.File && window.FileReader && window.webkitRequestFileSystem,都不支持时提示“请使用 Chrome/Edge 浏览器”。 - 如果是老浏览器但用户必须用,提供层级选择组件,让用户手工在系统里建目录,然后逐个文件上传。
- 不要强行兼容 IE,会让整个分片方案变成四不像,维护成本暴涨。
5.5 服务器抛出 404.13 或 413.0
这是最经典的问题。前端明明分片了,但报 404.13(请求内容过长)或 413(Request Entity Too Large),基本可以断定是 IISmaxAllowedContentLength或 httpRuntimemaxRequestLength没放开。排查时先按第 4 节的配置逐项核对。
还有一种隐蔽情况:同一个站点下多个应用,父级web.config有全局限制,子应用的配置没生效。用appcmd设置请求大小时,注意看一下是设到了哪个站点或应用上,别改错地方。
5.6 上传到一半断连的恢复
工程现场的网络不稳定,专线也可能抖动。断点续传是必须的,但很多自研方案只做了“分片重传”,没做“已完成分片跳过”。这意味着用户断网五分钟,再回来要把整个文件夹重新传一遍,几千个文件哪怕全是好的也要重新请求一遍。一定要实现/check-chunks接口,前端在文件级判断跳过已传分片,最好再保存一份上传进度到localStorage,刷新页面后直接从断点位置继续。
服务端也要考虑:用户传完一半不再传了,这些临时分片哪些该清理、哪些该保留?我按“距最后写入时间”清理,保留 24 小时内的分片,给用户第二天继续传的机会。
最后说一点生产环境下的体会
这套方案上线后,我们那边最大的项目一次拖进来 8000 多个文件、总容量约 42GB,用了差不多一小时传完,中途断过两次网都续上了,目录树和本地完全一致。资料员从以前的一天传不完,变成拖进去就能下班,算是对这套设计最大的肯定。
我的体会是,大文件夹上传这件事,难点不在代码量,而在对工程业务的理解。你一定要把“目录结构”当成一等公民来对待,前端别丢路径,后端别拍平文件,数据库别只存文件名。把这三个原则守住,再配合分片、断点续传、并发控制,这套系统就稳了。如果你正准备从零做,建议先把第 1 节的需求清单和资料员聊透,再做第 2 节的选型,别一上来就抄代码。架构选对了,后面所有实现都是水到渠成的事。