
简介面向 .NET Core WebApi 开发者的文件上传下载服务实现示例适合后端初学者与需要搭建文件管理模块的项目团队。内容围绕文件接口开发覆盖文件对象的接收、表单上传数据的解析、下载响应头与流式传输同时涉及权限校验、路径防遍历、文件名清理和异步性能优化。包内共五十个文件以 C# 源码为主包含配置文件、工程文件、容器部署文件、前端示例和说明文档压缩包大小约二百零六KB目录划分了服务端、前端演示、客户端示例等多个子项目并提供上传下载控制器、缩略图中间件与负载均衡中间件的参考实现。目前已有一千九百二十一人浏览学习。参考其中控制器、中间件与配置实现可以快速掌握文件服务的读写思路和常见安全坑位并迁移到实际项目中按需改造。1. 为什么用 .NET Core 做文件服务不只是上传下载这么简单我接过不少“帮我搭个文件上传”的需求但真正落地时文件上传下载服务从来不是加两个接口那么简单。.NET Core WebApi 做文件服务胜在生态统一、跨平台部署省心而且从 .NET Core 3.1 到 .NET 6/7/8上传下载的基础能力一直在收敛成熟。这篇文章我会把一套可上线的 WebApi 文件服务完整拆给你看——从项目初始化、基础上传下载到分片上传、Range 断点续传、鉴权设计以及我踩过的五个真实坑。文件上传攻击防护会重点讲这部分不做等于裸奔。适合刚接触 .NET Core 的初学者照着复现也适合已经写了多个接口但总在文件上传上翻车的熟手对照排查。2. 项目初始化与基础文件接口先把最小闭环跑通2.1 项目结构设计与存储目录规划搞文件服务第一步不是写代码而是想清楚文件存哪儿、怎么命名、目录怎么组织。常见的做法是存储路径与代码分离配置放在 appsettings.json 里按业务拆分子目录避免单目录文件数过多数据库只存文件元信息路径、大小、类型、上传时间二进制不落库。我一般会用一个FileStorage/作为根目录下面按yyyy/MM/dd生成日期分层目录。这样做的最大好处是备份可以按天做清理过期文件也方便。等文件量上来了或者需要多机共享时再考虑迁移到 MinIO 或云对象存储前期本地磁盘足够。创建项目的命令dotnet new webapi -n FileService.Api cd FileService.Api dotnet add package Microsoft.AspNetCore.Http这里用dotnet new webapi生成的是 minimal API 模板如果你更习惯传统的 Controller 写法手动建一个 Controllers 文件夹就行效果一样。Microsoft.AspNetCore.Http是处理IFormFile的依赖包默认模板会隐式引用但显式 add 一遍更保险避免后续升级模板时依赖丢失。2.2 上传接口的最小实现从 IFormFile 到落盘先写一个最小可用的上传接口把整个流程跑通再一步步加防护。我的习惯是先做主流程再做加固别一上来就把代码写成一坨。[ApiController] [Route(api/files)] public class FileController : ControllerBase { private readonly IWebHostEnvironment _env; private readonly IConfiguration _config; public FileController(IWebHostEnvironment env, IConfiguration config) { _env env; _config config; } [HttpPost(upload)] public async TaskIActionResult Upload(IFormFile file) { // 校验文件是否为空 if (file null || file.Length 0) return BadRequest(new { message 文件不能为空 }); // 生成存储相对路径2025/01/15/guid_文件名 var datePath DateTime.Now.ToString(yyyy/MM/dd); var fileName ${Guid.NewGuid():N}_{Path.GetFileName(file.FileName)}; var relativePath Path.Combine(datePath, fileName); // 物理落盘 var rootPath _config[Storage:RootPath] ?? Path.Combine(_env.ContentRootPath, FileStorage); var fullPath Path.Combine(rootPath, relativePath); var dir Path.GetDirectoryName(fullPath); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); await using var stream new FileStream(fullPath, FileMode.Create); await file.CopyToAsync(stream); // 返回可访问的相对路径 return Ok(new { path relativePath.Replace(\\, /), size file.Length, contentType file.ContentType }); } }逻辑说明这段代码做了四件事——校验空文件、生成带 GUID 的文件名避免并发冲突、按日期归属目录、落盘后返回相对路径。Guid.NewGuid():N去掉连字符后得到 32 位十六进制字符串在高并发场景下几乎不会重复。参数说明IFormFile是 ASP.NET Core 对 multipart/form-data 文件的抽象CopyToAsync内部走异步流拷贝不会阻塞线程池。Storage:RootPath建议配成绝对路径别丢在 wwwroot 下——wwwroot 里的文件默认会被静态文件中间件直接暴露这等于把文件服务的安全性交给运气。2.3 下载接口与静态文件映射下载相对简单但细节不少。我习惯提供两个下载入口一个走 Controller 返回文件流用于需要鉴权的场景一个走静态文件中间件用于公开文件的快速访问。Controller 方式下载接口[HttpGet(download/{**path})] public async TaskIActionResult Download(string path) { var rootPath _config[Storage:RootPath] ?? Path.Combine(_env.ContentRootPath, FileStorage); // 防路径穿越把 .. 和绝对路径都过滤掉 if (string.IsNullOrWhiteSpace(path) || path.Contains(..)) return BadRequest(new { message 非法路径 }); var fullPath Path.GetFullPath(Path.Combine(rootPath, path.Replace(/, Path.DirectorySeparatorChar.ToString()))); // 校验最终路径必须在 rootPath 内部 if (!fullPath.StartsWith(Path.GetFullPath(rootPath))) return BadRequest(new { message 非法路径 }); if (!System.IO.File.Exists(fullPath)) return NotFound(new { message 文件不存在 }); var memory new MemoryStream(); await using (var stream new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read)) { await stream.CopyToAsync(memory); } memory.Position 0; var contentType application/octet-stream; return File(memory, contentType, Path.GetFileName(fullPath)); }逻辑说明路由里的{**path}是 ASP.NET Core 的 catch-all 路由参数能匹配download/2025/01/15/xxx.jpg这样的多级路径。路径穿越防护是必须的——Path.GetFullPath会把..展开成实际路径再通过StartsWith判断是否还在根目录内部这一步对安全至关重要。参数说明FileShare.Read允许多个请求同时读同一个文件不会因为一个请求占用就把文件锁死。这里先把整个文件读进 MemoryStream 再返回对小文件10MB 以下完全够用但大文件不能这么干后面章节会讲怎么改成流式返回。如果部分文件希望公开访问可以在 Program.cs 里加静态文件映射app.UseStaticFiles(new StaticFileOptions { FileProvider new PhysicalFileProvider( Path.Combine(env.ContentRootPath, FileStorage, public)), RequestPath /files });这段代码的作用是把FileStorage/public目录映射到/files这个 URL 前缀浏览器直接访问http://localhost:5000/files/2025/01/15/test.jpg就能拿到文件不走鉴权适合图片、PDF、压缩包这类公开资源。3. 上传加固命名策略、类型校验与大文件分片3.1 文件名安全策略为什么要做 GUID 重命名很多初学 .NET Core 的后端拿到file.FileName直接拼路径就存了这是文件上传攻击最经典的入口。攻击者可以构造../../etc/passwd或带特殊字符的文件名如果你的代码直接用了Path.Combine会踩两种坑路径穿越和文件覆盖。我在这里会用两步处理private string SafeFileName(string originalName) { // 去掉路径部分只保留纯文件名 var cleanName Path.GetFileName(originalName); // 替换掉文件名里的非法字符 foreach (var c in Path.GetInvalidFileNameChars()) { cleanName cleanName.Replace(c.ToString(), _); } // 加上 GUID 前缀防止重名覆盖 return ${Guid.NewGuid():N}_{cleanName}; }逻辑说明Path.GetFileName把../../evil.exe变成evil.exe先断掉路径穿越的路GetInvalidFileNameChars处理 Windows/Linux 下不合法的文件名符号GUID 前缀解决并发重名问题。这三步缺一个都不够稳。扩展名白名单要不要做我的建议是看场景。如果文件只做私密存储不对外提供访问可以不做如果用户上传的图片或附件会被浏览器直接打开那白名单必须加否则一个.html或.svg文件就能在别人浏览器里执行脚本。var allowedExtensions new[] { .jpg, .jpeg, .png, .gif, .pdf, .zip }; var ext Path.GetExtension(file.FileName).ToLowerInvariant(); if (!allowedExtensions.Contains(ext)) return BadRequest(new { message $不允许的文件类型: {ext} });这里有个容易被忽略的点file.ContentType是客户端上报的不可信Path.GetExtension是服务端解析的也不完全可信但结合内容嗅探读文件头几个字节比对魔数后能挡住大部分伪装文件。魔数校验代码不复杂但很多人懒得做我后面在避坑章节补细节。3.2 大文件分片上传前端切片 后端合并文件超过 100MB 时单次上传的成功率直线下降尤其跨运营商网络。常见的做法是前端把文件切成 2MB~10MB 的块后端按顺序接收合并最后合成完整文件。前端核心逻辑const CHUNK_SIZE 5 * 1024 * 1024; // 5MB 一片 const file fileInput.files[0]; const totalChunks Math.ceil(file.size / CHUNK_SIZE); const fileId crypto.randomUUID(); for (let i 0; i totalChunks; i) { const start i * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); const chunk file.slice(start, end); const formData new FormData(); formData.append(fileId, fileId); formData.append(chunkIndex, i); formData.append(totalChunks, totalChunks); formData.append(fileName, file.name); formData.append(file, chunk); await fetch(/api/files/chunk-upload, { method: POST, body: formData }); } // 全部传完后通知合并 await fetch(/api/files/chunk-merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileId: fileId, fileName: file.name, totalChunks: totalChunks }) });逻辑说明file.slice是浏览器原生 Blob 切割 API不会增加内存占用每个分片都带fileId让服务端知道这些分片属于同一个文件chunkIndex是分片序号因为网络传输不一定按顺序到达。全部传完之后前端发起一次合并请求服务端把分片按序拼回完整文件。后端接收分片和合并接口[HttpPost(chunk-upload)] public async TaskIActionResult UploadChunk( [FromForm] string fileId, [FromForm] int chunkIndex, [FromForm] int totalChunks, [FromForm] string fileName, IFormFile file) { var chunkDir Path.Combine(_config[Storage:TempPath], fileId); Directory.CreateDirectory(chunkDir); var chunkPath Path.Combine(chunkDir, ${chunkIndex}.part); await using var stream new FileStream(chunkPath, FileMode.Create); await file.CopyToAsync(stream); return Ok(new { received chunkIndex }); }分片大小怎么定5MB 是一个通用折中值具体看网络环境分片大小适用场景1GB 文件的请求次数优缺点2MB公网弱网512失败重试代价小但请求次数多耗时更长5MB通用205请求次数与重试成本平衡多数场景首选10MB内网/高带宽103请求少更快但网络抖动时失败重传代价高合并接口[HttpPost(chunk-merge)] public async TaskIActionResult MergeChunks([FromBody] MergeRequest request) { var chunkDir Path.Combine(_config[Storage:TempPath], request.FileId); if (!Directory.Exists(chunkDir)) return BadRequest(new { message 分片目录不存在 }); var datePath DateTime.Now.ToString(yyyy/MM/dd); var fileName SafeFileName(request.FileName); var targetPath Path.Combine(_config[Storage:RootPath], datePath, fileName); Directory.CreateDirectory(Path.GetDirectoryName(targetPath)); // 按序号顺序合并 await using var target new FileStream(targetPath, FileMode.Create); for (int i 0; i request.TotalChunks; i) { var chunkPath Path.Combine(chunkDir, ${i}.part); if (!System.IO.File.Exists(chunkPath)) return BadRequest(new { message $缺少分片 {i} }); await using var chunk new FileStream(chunkPath, FileMode.Open); await chunk.CopyToAsync(target); } // 合并完清理临时分片 Directory.Delete(chunkDir, true); return Ok(new { path ${datePath}/{fileName} }); }参数说明临时分片存放在独立TempPath和正式存储目录分开避免合并失败时污染正式数据。合并时按0.part、1.part顺序逐个追加如果中间缺片立即返回错误并带上缺少的序号前端可以针对性重传。合并完成后删除临时目录不占磁盘空间。3.3 Kestrel 请求体限制与并发参数调优上传接口是 IO 密集型的异步化是底线。ASP.NET Core 默认的 Kestrel 配置对开发环境够用但生产环境必须调几个参数否则传大文件时请求体会被 Kestrel 直接拒绝。builder.WebHost.ConfigureKestrel(options { options.Limits.MaxRequestBodySize 2L * 1024 * 1024 * 1024; // 2GB options.Limits.KeepAliveTimeout TimeSpan.FromMinutes(2); options.Limits.RequestHeadersTimeout TimeSpan.FromSeconds(30); });逻辑说明MaxRequestBodySize默认是 30MB超过直接 413 错误这是很多人第一次传大文件翻车的根源。调到 2GB 以后传大文件不会被 Kestrel 拦。KeepAliveTimeout给长连接留了足够的缓冲分片上传这种连续请求场景不会因为空闲时间稍长就被断开。注意如果部署在 IIS 后面还有一层maxAllowedContentLength限制默认同样是 30MB需要在 web.config 里同步改大system.webServer security requestFiltering requestLimits maxAllowedContentLength2147483648 / /requestFiltering /security /system.webServer这个配置容易漏掉。我当时排查 413 查了半天才发现是 IIS 层拦截不是 Kestrel 的问题。另外如果用 Nginx 反代client_max_body_size默认只有 1MB也需要同步调大。4. 下载进阶Range 请求、断点续传与鉴权设计4.1 支持 Range 请求的流式下载2.3 里的下载实现把文件整个读进内存再返回对 1GB 视频文件来说会直接把内存打爆。正确做法是使用FileStreamResult或手动处理 Range 请求。[HttpGet(stream/{**path})] public IActionResult Stream(string path) { // 路径校验与前面一致这里省略 var fullPath Path.Combine(rootPath, path); var fileInfo new FileInfo(fullPath); if (!fileInfo.Exists) return NotFound(); var stream new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read); return File(stream, application/octet-stream, fileInfo.Name, enableRangeProcessing: true); }参数说明enableRangeProcessing: true是 ASP.NET Core 3.0 之后提供的关键参数它让框架自动处理 HTTP 的Range头——客户端发Range: bytes0-1023时只返回对应字节段并带206 Partial Content状态码。这直接满足两个场景下载器多线程并发下载、视频播放器拖动进度条。注意我这次没有用 MemoryStreamFileStreamResult是流式的数据从磁盘读出来后直接被写入响应管道不会整文件占用托管堆内存。enableRangeProcessing开启后如果客户端没发 Range 头行为退化为普通全量下载向前兼容没问题。4.2 下载鉴权临时 Token 与过期链接很多文件不能公开访问Controller 下载接口天然适合做鉴权。常见做法是签发带过期时间的临时 Token附加在下载 URL 的 QueryString 上。[HttpGet(auth-download)] public async TaskIActionResult AuthDownload( [FromQuery] string token, [FromQuery] string path) { // 校验 token 有效性和过期时间 if (!_tokenService.Validate(token, out var expireAt)) return Unauthorized(new { message 链接无效或已过期 }); if (expireAt DateTime.UtcNow) return Unauthorized(new { message 链接已过期 }); // 校验通过后走文件流返回 var fullPath Path.Combine(rootPath, path); if (!System.IO.File.Exists(fullPath)) return NotFound(); var stream new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read); return File(stream, application/octet-stream, Path.GetFileName(fullPath), enableRangeProcessing: true); }逻辑说明token里面编码三样东西——文件相对路径、过期时间戳、一个随机 nonce然后用 HMAC-SHA256 签名。服务端不需要存 token 状态拿到后验签加比对过期时间即可判断有效性做到无状态鉴权多实例部署时也不用同步 session。Token 生成关键代码public string Generate(string path, TimeSpan ttl) { var expireAt DateTimeOffset.UtcNow.ToUnixTimeSeconds() (long)ttl.TotalSeconds; var payload ${path}|{expireAt}|{Guid.NewGuid():N}; var signature _hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)); return ${Convert.ToBase64String(Encoding.UTF8.GetBytes(payload))}. ${Convert.ToBase64String(signature)}; }参数说明TTL 典型值是 5 分钟到 24 小时。太短影响用户体验太长链接泄露后风险增大。图片附件我给 24 小时视频等大文件给 1 小时。注意 Base64 在 URL 里会有和/字符要替换成 URL 安全的-和_不然有些客户端会解析失败。4.3 响应头细节与浏览器兼容下载文件时响应头和文件名编码是浏览器兼容性的重灾区。中文文件名在 Chrome、Firefox、IE 上的处理方式不一样推荐用 RFC 5987 格式同时提供两种编码var encodedFileName Uri.EscapeDataString(fileInfo.Name); Response.Headers.Add(Content-Disposition, $attachment; filename\{encodedFileName}\; filename*UTF-8{encodedFileName});同时建议显式加上这几个响应头响应头作用建议值X-Content-Type-Options禁止浏览器 MIME 嗅探防止类型混淆攻击nosniffCache-Control控制下载链接是否允许缓存private, max-age60Content-Length断点续传时配合 Range 返回实际字节数由框架自动处理X-Content-Type-Options: nosniff禁止浏览器对响应内容进行 MIME 嗅探防止上传了伪装类型文件时浏览器执行意外脚本。Cache-Control: private表示只允许客户端本地缓存代理服务器不缓存避免敏感文件被 CDN 兜住导致回收困难。5. 文件服务避坑指南五个实战踩坑记录5.1 上传 413 错误Kestrel、IIS 与 Nginx 的三重限制现象开发环境传 100MB 文件没问题部署到 Windows Server 的 IIS 后传 30MB 以上的文件直接返回 413 Request Entity Too Large。原因Kestrel 默认MaxRequestBodySize是 30MBIIS 的maxAllowedContentLength默认也是 30MB。如果前面还挂了 Nginxclient_max_body_size默认只有 1MB。三层限制任何一层没改文件都传不上去。解决三层都要改。Kestrel 写在 Program.cs 里IIS 改 web.configNginx 改client_max_body_size。这个排查顺序很重要我之前先改了 Kestrel 看到 413 还在一度怀疑框架版本有问题最后发现是 IIS 层卡住。三层配置缺一不可。5.2 Multipart 边界解析失败Content-Type 没带 boundary现象Postman 直接传文件没问题某个第三方客户端上传时后端拿到的file是 null或者抛Multipart body length limit exceeded异常。原因客户端请求头Content-Type里的boundary参数丢了。IFormFile的解析依赖 boundary 来切分 multipart 报文boundary 缺失时整个 body 无法解析框架直接把它当成普通表单。解决排查客户端上传代码里Content-Type头的设置确保是完整的multipart/form-data; boundary----WebKitFormBoundaryXXX格式。自定义 HTTP 客户端千万别手写 boundary 字符串用MultipartFormDataContent让它自动生成否则格式稍微不对就会踩这个坑。5.3 大文件下载内存暴涨File 参数里的 Stream 生命周期现象并发下载 500MB 文件时服务内存持续升高最后触发 OOM进程被操作系统杀掉。原因代码里手动把 FileStreamCopyTo到 MemoryStream 再返回。每个请求都完整复制一份文件到内存10 个并发 500MB 就是 5GB 内存占用不爆才怪。这是典型的流生命周期管理失误。解决直接返回 FileStream不要绕 MemoryStream。File(stream, contentType, fileName, enableRangeProcessing: true)让框架内部的流复制操作负责转发数据不经过托管堆内存占用几乎为零。这是血泪经验我之前一套代码统一走 MemoryStream结果压测时直接翻车。小文件可以容忍超过 50MB 必须走流式。5.4 路径遍历漏洞Path.Combine 不是安全边界现象我用 OWASP ZAP 跑了一遍上传下载接口扫描报告提示存在路径遍历漏洞攻击者把请求路径改成download/../../etc/passwd可能读取任意文件。原因代码对 path 参数只做了Path.Combine(rootPath, path)拼接没有校验..和绝对路径。Path.Combine的第二个参数以../开头时会向上回退直接拼出根目录之外的文件路径这等于把服务器文件系统打开给攻击者。解决三层防护缺一不可。第一层用Path.GetFileName去掉目录段第二层用Path.GetFullPath展开物理路径第三层用StartsWith(Path.GetFullPath(rootPath))判断最终路径是否还在存储根目录内部。三层同时过了才允许读文件。文件上传攻击的模式很多路径穿越是最基础的一种黑匣子别留。5.5 分片上传合并缺片断点续传的前端重试策略现象合并接口报缺少分片 3整个文件上传失败用户只能从头重传体验极差。原因弱网环境下某个分片的 POST 请求超时前端fetch没做重试分片数据根本没到服务端但前端以为正常返回了。合并时才发现缺片。解决前端加分片重试机制——每个分片失败时最多重试 3 次采用指数退避等待 1 秒、2 秒、4 秒。后端合并接口设计成幂等前端收到缺片错误时只重传对应序号的分片不用全量重来。另外合并前先查元数据表如果该文件已经存在且大小一致直接返回已有路径不重复落盘这一步能省大量磁盘。6. 文件服务上线前最后的验证压测三件套与日志埋点文件服务和普通接口最大的区别在于前者有明确的 IO 边界你必须在真实流量压力下验证过才算真正交付。我习惯上线前强制做三件事缺一不可压测、日志、磁盘清理策略。压测我用 ab 或 wrk 打上传和下载接口重点观察两个指标——错误率和内存曲线。ab 命令测下载接口的并发能力ab -n 100 -c 10 -H Range: bytes0-1048575 \ http://localhost:5000/api/files/stream/2025/01/15/test.bin这个命令模拟 10 个并发请求每个只取文件前 1MB共发 100 次。如果错误率超过 1%或内存曲线一直上升不回落说明 IO 或资源释放有问题需要回去检查 FileShare 和 Stream 的 Dispose。压测时记得用 200MB 以上的大文件测下载用 50MB 的文件测上传小文件测不出真实瓶颈。链路日志是排障的后悔药。文件服务出了问题最难定位的就是不知道请求卡在哪个环节。我每次上线前都会在五个关键点埋结构化日志收到请求、开始读文件、开始写文件、合并完成、删除临时文件。每条日志带上 TraceId生产环境没法断点调试日志是唯一的排查线索。日志格式我一般用埋点位置记录内容排查价值收到上传请求文件名、大小、ContentType快速定位是请求没到还是处理慢开始落盘目标路径、磁盘剩余空间排查磁盘 IO 和空间不足合并完成分片数、总耗时验证分片上传是否正常删除临时文件临时目录、释放大小确认磁盘回收正常磁盘清理策略。文件服务跑久了磁盘会被孤儿文件堆满——用户上传后没走完业务流程、服务端合并失败残留的分片、过期未清理的临时文件都会变成垃圾。我建议每天一个定时任务扫描存储目录删除 7 天前且未被数据库引用的文件。这个策略简单但对大部分场景够用至少不会让磁盘悄悄写满。最后补一个很多人忽略的技巧上传接口必须做幂等。用户在弱网下点击上传时前端会重复提交同一个文件或分片。服务端先查元数据表如果该文件的 FileId 已经存在且大小一致直接返回已有路径不重复落盘。这个习惯是我在一家物流公司的生产事故里学到的——用户重复提交导致磁盘写满了多份副本最后凌晨三点爬起来清数据。从那以后我每次写文件服务都会强制把幂等校验放在所有写接口的入口处上传是这样合并是这样秒传也是这样。这套经验分享给你希望帮到你。本文还有配套的精品资源点击获取