
干过工程建筑类信息系统的朋友应该都有过被图纸和模型折磨的经历。设计院交付一套施工图按专业分建筑、结构、机电每个专业下面再按楼栋、楼层、图号继续分一位资料员一次性要归档的东西轻则几百兆重则几十个G。.NET MVC 原生的文件上传说白了就是单个input typefile一次一个文件页面上传完再把目录结构手动建一遍这套流程在单体项目里或许够用放到一个真实的项目管理系统里基本等于原地爆炸。这篇文章不是讲理论而是把我做 .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 typefile单文件上传的死穴最原始的做法是表单整包提交。前端一个表单enctypemultipart/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 typefile idfolderPicker webkitdirectory multiple button onclickdocument.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 TaskIActionResult 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 TaskIActionResult 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 Listint(); 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 TaskIActionResult 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 不同时自动生成新版本旧版本保留并标记IsCurrent0这样既能避免误覆盖又能满足资料版本追溯的需求。4. 配置文件与 IIS 限制这步错了直接白干代码写对了配置不对一样传不上来。我见过太多项目在“文件上传最大 30MB”的默认限制下测试大文件夹怎么传怎么挂最后发现根本不是代码问题。这一节把 .NET 体系和 IIS 的各个限制位置全部列清楚。4.1 MVC 5 与 .NET Core 的配置差异如果是传统的 .NET Framework MVC 5需要修改web.configconfiguration system.web !-- maxRequestLength 单位是 KB这里大约 10GB -- httpRuntime maxRequestLength10485760 executionTimeout3600 / /system.web system.webServer security requestFiltering !-- requestLimits 单位是 Bytes这里大约 10GB -- requestLimits maxAllowedContentLength10737418240 / /requestFiltering /security /system.webServer /configuration这两处必须同时设因为生效层级不同。httpRuntime管的是 ASP.NET 引擎接收的请求体大小requestFiltering管的是 IIS 层面的请求体大小只要有一处没放开大请求就会被拦掉。如果是 ASP.NET Core MVCKestrel 托管在appsettings.json里配{ Kestrel: { Limits: { MaxRequestBodySize: 10737418240 } } }也可以用属性控制器级配置[RequestSizeLimit(10L * 1024 * 1024 * 1024)] [HttpPost(api/upload/chunk)] public async TaskIActionResult 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 appConcurrentRequestLimit65535 / /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 长路径支持注册表LongPathsEnabled1要么在后端对路径做压缩映射。最推荐的还是启用长路径然后代码里避免对路径做无意义的拼接。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请求内容过长或 413Request Entity Too Large基本可以断定是 IISmaxAllowedContentLength或 httpRuntimemaxRequestLength没放开。排查时先按第 4 节的配置逐项核对。还有一种隐蔽情况同一个站点下多个应用父级web.config有全局限制子应用的配置没生效。用appcmd设置请求大小时注意看一下是设到了哪个站点或应用上别改错地方。5.6 上传到一半断连的恢复工程现场的网络不稳定专线也可能抖动。断点续传是必须的但很多自研方案只做了“分片重传”没做“已完成分片跳过”。这意味着用户断网五分钟再回来要把整个文件夹重新传一遍几千个文件哪怕全是好的也要重新请求一遍。一定要实现/check-chunks接口前端在文件级判断跳过已传分片最好再保存一份上传进度到localStorage刷新页面后直接从断点位置继续。服务端也要考虑用户传完一半不再传了这些临时分片哪些该清理、哪些该保留我按“距最后写入时间”清理保留 24 小时内的分片给用户第二天继续传的机会。最后说一点生产环境下的体会这套方案上线后我们那边最大的项目一次拖进来 8000 多个文件、总容量约 42GB用了差不多一小时传完中途断过两次网都续上了目录树和本地完全一致。资料员从以前的一天传不完变成拖进去就能下班算是对这套设计最大的肯定。我的体会是大文件夹上传这件事难点不在代码量而在对工程业务的理解。你一定要把“目录结构”当成一等公民来对待前端别丢路径后端别拍平文件数据库别只存文件名。把这三个原则守住再配合分片、断点续传、并发控制这套系统就稳了。如果你正准备从零做建议先把第 1 节的需求清单和资料员聊透再做第 2 节的选型别一上来就抄代码。架构选对了后面所有实现都是水到渠成的事。