.NET Core WebApi 文件上传下载:从接口能跑到敢上生产
发布时间:2026/9/29 1:37:32来源:尧图网络
简介这份资源面向.NET Core后端开发者与WebAPI初学者聚焦文件上传与下载服务的完整实现帮助解决multipart/form-data解析、流式响应、权限校验与性能优化等常见痛点。压缩包共50个文件约206KB以25个C#源码文件为核心配合9个JSON配置、5个csproj工程文件、4个JavaScript脚本及Dockerfile、sln解决方案、readme说明等涵盖控制器、中间件、配置模型与前端演示模块结构清晰便于按模块研读。已有1921人学习下载。通过其中的示例代码读者可掌握IFormFile接收上传、Content-Disposition与Content-Type响应头设置、异步流式读写、分块传输、JWT鉴权、路径遍历防护及文件类型限制等关键技巧并参考中间件实现缩略图、负载均衡上传等扩展思路快速搭建可落地的文件服务。1. .NET Core WebApi 文件上传下载从接口能跑到敢上生产文件上传和文件下载几乎是每个 .NET Core WebApi 项目都绕不开的两个接口。看起来简单——上传就是收IFormFile下载就是返回FileStreamResult但真正放到生产环境里问题一个接一个大文件上传内存爆掉、中文文件名乱码、下载时浏览器直接打开而不是弹出保存框、上传目录被恶意脚本利用。这篇笔记不讲空泛概念而是把我在实际项目里踩过的坑和验证过的方案完整拆开从最小可运行代码到参数调优、安全边界一步步说清楚。适合正在用 .NET Core 写 WebApi 的开发者尤其是第一次做文件服务、或者做完之后发现线上总出问题的朋友。读完你能拿到一套可以直接抄的接口实现以及一份避坑清单。2. 最小可运行的文件上传下载接口2.1 用 IFormFile 接收上传Controller 写法和参数含义先建一个普通的 ASP.NET Core WebApi 项目目标框架选 .NET 6 或 .NET 8 都行差异不大。核心是把上传接口写对。[ApiController] [Route(api/[controller])] public class FileController : ControllerBase { private readonly IWebHostEnvironment _env; public FileController(IWebHostEnvironment env) { _env env; } // 上传接口接收单个文件 [HttpPost(upload)] [RequestSizeLimit(100 * 1024 * 1024)] // 限制单次请求体最大 100MB public async TaskIActionResult Upload(IFormFile file) { if (file null || file.Length 0) return BadRequest(未选择文件); // 生成安全的存储文件名避免用户原始文件名带来的路径穿越 var ext Path.GetExtension(file.FileName); var safeName ${Guid.NewGuid():N}{ext}; // 存储目录wwwroot/uploads var uploadDir Path.Combine(_env.WebRootPath, uploads); if (!Directory.Exists(uploadDir)) Directory.CreateDirectory(uploadDir); var savePath Path.Combine(uploadDir, safeName); // 流式写入避免一次性读进内存 using (var stream new FileStream(savePath, FileMode.Create)) { await file.CopyToAsync(stream); } return Ok(new { fileName safeName, originalName file.FileName, size file.Length }); } }这段代码有几个关键点。IFormFile是 ASP.NET Core 对上传文件的抽象file.Length是字节数file.FileName是客户端传来的原始文件名——注意这个值不可信可能包含../之类的路径穿越字符所以存储时一定要自己生成文件名。RequestSizeLimit特性控制单次请求体上限默认大约是 30MB超过会直接返回 413。CopyToAsync是流式拷贝不会把整个文件读进内存这是处理大文件的基本要求。2.2 下载接口FileStreamResult 与 Content-Disposition 的正确设置下载接口最常见的翻车点是中文文件名乱码和浏览器行为不一致。[HttpGet(download/{fileName})] public IActionResult Download(string fileName) { // 防止路径穿越只取文件名部分 var safeFileName Path.GetFileName(fileName); var uploadDir Path.Combine(_env.WebRootPath, uploads); var filePath Path.Combine(uploadDir, safeFileName); if (!System.IO.File.Exists(filePath)) return NotFound(文件不存在); var stream new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); var contentType application/octet-stream; // 关键用 ContentDispositionHeaderValue 处理中文文件名 var cd new Microsoft.Net.Http.Headers.ContentDispositionHeaderValue(attachment); cd.SetHttpFileName(safeFileName); // 自动处理 filename 和 filename* 编码 Response.Headers.Append(Content-Disposition, cd.ToString()); return new FileStreamResult(stream, contentType); }Content-Disposition设为attachment才会触发浏览器下载而不是直接打开。中文文件名必须用filename*UTF-8编码格式SetHttpFileName方法会自动处理这个。如果手动拼字符串很容易在 Chrome 和 Firefox 上表现不一致。FileStreamResult内部会做流式传输不需要自己读成 byte 数组。2.3 在 Program.cs 里配置上传大小限制和静态文件光在 Controller 上加RequestSizeLimit还不够Kestrel 和 FormOptions 也有各自的限制。var builder WebApplication.CreateBuilder(args); // 配置 FormOptions影响 multipart 解析 builder.Services.ConfigureFormOptions(options { options.MultipartBodyLengthLimit 200 * 1024 * 1024; // 200MB options.ValueLengthLimit int.MaxValue; options.MultipartHeadersLengthLimit int.MaxValue; }); // Kestrel 层面的请求体上限 builder.WebHost.ConfigureKestrel(options { options.Limits.MaxRequestBodySize 200 * 1024 * 1024; }); builder.Services.AddControllers(); var app builder.Build(); app.UseStaticFiles(); // 如果需要直接通过 URL 访问上传的文件 app.MapControllers(); app.Run();这三处限制要同时放开才有效RequestSizeLimit是 MVC 层的FormOptions.MultipartBodyLengthLimit是 multipart 解析层的Kestrel.MaxRequestBodySize是服务器层的。只改一个大文件上传照样失败。参数值根据实际业务定一般 100MB 到 500MB 之间再大就要考虑分片上传了。3. 上传安全别让文件服务变成入侵入口3.1 文件上传攻击的常见路径与后缀白名单策略文件上传漏洞是 Web 安全里最经典的攻击面之一。攻击者上传一个.aspx或.php文件到可访问目录然后直接请求执行就能拿到服务器权限。在 .NET Core 里虽然不像 PHP 那么容易被直接执行但如果服务器前面挂了 Nginx 或 Apache 做反向代理配置不当同样有风险。防御的核心是白名单不是黑名单。黑名单永远列不全而且像.phtml、.php5、.ashx这些变体很容易绕过。我一般只允许业务真正需要的后缀private static readonly HashSetstring AllowedExtensions new(StringComparer.OrdinalIgnoreCase) { .jpg, .jpeg, .png, .gif, .bmp, .pdf, .doc, .docx, .xls, .xlsx, .zip, .rar, .7z, .txt, .csv }; // 在 Upload 方法开头加校验 var ext Path.GetExtension(file.FileName); if (string.IsNullOrEmpty(ext) || !AllowedExtensions.Contains(ext)) return BadRequest($不支持的文件类型{ext});注意Path.GetExtension拿到的是最后一个点之后的部分攻击者用test.jpg.php这种双后缀拿到的是.php会被拦掉。但还要注意大小写和 URL 编码所以用OrdinalIgnoreCase比较。3.2 校验 Content-Type 和文件头别只信后缀后缀可以伪造Content-Type 也可以伪造但文件头Magic Number相对难改。对图片类上传我一般会读前几个字节做二次校验。private static bool IsValidImage(IFormFile file) { // 只读前 8 个字节判断文件头 using var stream file.OpenReadStream(); var header new byte[8]; var read stream.Read(header, 0, 8); if (read 4) return false; // JPEG: FF D8 FF if (header[0] 0xFF header[1] 0xD8 header[2] 0xFF) return true; // PNG: 89 50 4E 47 if (header[0] 0x89 header[1] 0x50 header[2] 0x4E header[3] 0x47) return true; // GIF: 47 49 46 38 if (header[0] 0x47 header[1] 0x49 header[2] 0x46 header[3] 0x38) return true; return false; }这个校验不能替代后缀白名单而是叠加使用。对于文档类文件文件头校验意义不大重点还是放在存储隔离和访问控制上。3.3 存储目录隔离上传目录不要给执行权限最容易被忽视的一点上传目录绝对不能有脚本执行权限。在 IIS 里给 uploads 目录单独配置移除“执行”权限只保留“读取”。在 Nginx 里加一条 location 规则location /uploads/ { # 禁止执行任何脚本 location ~ \.(php|asp|aspx|jsp|ashx|asmx)$ { deny all; } # 只允许静态文件访问 add_header Content-Disposition attachment; }另外上传目录最好放在 Web 根目录之外通过接口读取后再输出而不是让静态文件中间件直接暴露。这样即使有人上传了恶意文件也无法通过 URL 直接访问执行。4. 大文件与并发场景下的参数调优4.1 流式处理 vs 缓冲大文件上传的内存控制默认情况下ASP.NET Core 对小于 64KB 的表单文件会缓冲到内存超过的会写到临时文件。但如果代码里用了file.OpenReadStream().CopyTo(memoryStream)这种写法等于把文件全读进内存了。大文件并发上传时内存会迅速飙升。正确的做法始终是流式处理// 正确流式写入目标文件 using var sourceStream file.OpenReadStream(); using var targetStream new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true); await sourceStream.CopyToAsync(targetStream, 81920);81920是 80KB 的缓冲区大小这是 .NET 内部流拷贝的默认值一般不需要改。useAsync: true对异步写入很重要否则CopyToAsync实际上是在线程池线程上同步写。如果确实需要临时缓冲用FileStream而不是MemoryStream。4.2 分片上传的接口设计什么时候需要怎么切当文件超过 500MB 或者网络不稳定时单次上传体验很差。分片上传的核心思路是把文件切成固定大小的块逐块上传最后合并。接口设计一般三个接口方法作用/api/file/initPOST初始化上传任务返回 uploadId/api/file/chunkPOST上传单个分片携带 uploadId、chunkIndex/api/file/mergePOST所有分片上传完成后合并分片大小一般设 2MB 到 5MB。太小请求次数多太大失去分片意义。合并时按 chunkIndex 顺序追加写入[HttpPost(merge)] public async TaskIActionResult Merge([FromBody] MergeRequest req) { var tempDir Path.Combine(_env.WebRootPath, temp, req.UploadId); var finalPath Path.Combine(_env.WebRootPath, uploads, req.FileName); using var finalStream new FileStream(finalPath, FileMode.Create); for (int i 0; i req.TotalChunks; i) { var chunkPath Path.Combine(tempDir, ${i}.part); using var chunkStream new FileStream(chunkPath, FileMode.Open, FileAccess.Read); await chunkStream.CopyToAsync(finalStream); } Directory.Delete(tempDir, true); // 清理临时分片 return Ok(new { fileName req.FileName }); }分片上传还要考虑断点续传客户端上传前先问服务端哪些分片已经存在跳过已上传的。这需要在 init 接口返回已上传的分片列表。4.3 下载限速与 Range 请求支持大文件下载如果不限速一个客户端就能把带宽占满。另外视频类文件需要支持 Range 请求才能拖动进度条。ASP.NET Core 的FileStreamResult默认支持 Range但需要开启[HttpGet(download/{fileName})] public IActionResult Download(string fileName, bool enableRange true) { // ... 前面的路径校验 var stream new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); var result new FileStreamResult(stream, application/octet-stream); result.EnableRangeProcessing enableRange; result.FileDownloadName safeFileName; return result; }EnableRangeProcessing true后客户端带Range: bytes0-1023请求时服务端会返回 206 Partial Content。限速则需要自己实现一个包装流控制每秒读取的字节数这里不展开。5. 避坑与排查那些线上才暴露的问题5.1 上传后文件大小为 0 或内容截断现象接口返回成功但保存的文件是 0 字节或者只有一部分。原因最常见的是在CopyToAsync之前流已经被读过一次位置在末尾。比如先调用了IsValidImage(file)读了文件头没有把流位置重置后面再拷贝就是空的。解决每次读取后重置流位置或者用file.OpenReadStream()重新打开。IFormFile的OpenReadStream每次调用返回新的流但要注意缓冲文件的生命周期。5.2 中文文件名下载变成乱码或下划线现象下载下来的文件名是____.pdf或者一串百分号编码。原因Content-Disposition头里直接写了中文没有做 RFC 5987 编码。不同浏览器解析方式不同。解决用ContentDispositionHeaderValue.SetHttpFileName()它会同时生成filename和filename*UTF-8两个参数兼容所有主流浏览器。不要自己拼字符串。5.3 大文件上传返回 413 但改了配置还不生效现象明明在 Controller 上加了RequestSizeLimit还是 413。原因请求还没到 Controller 就被 Kestrel 或 FormOptions 拦了。三处限制是叠加的任何一处超限都会失败。解决同时配置Kestrel.MaxRequestBodySize、FormOptions.MultipartBodyLengthLimit和RequestSizeLimit。如果前面有 Nginx还要改client_max_body_size。5.4 上传目录被写入可执行文件现象安全扫描发现 uploads 目录下有.aspx或.php文件。原因后缀白名单没做或者只做了黑名单被绕过。另外静态文件中间件直接暴露了上传目录。解决白名单 文件头校验 存储目录不给执行权限 上传目录放在 Web 根之外。四层叠加不要只靠一层。5.5 Swagger 页面能上传但前端调用失败现象在 Swagger UI 里上传正常前端 axios 调用报 415 或 400。原因Swagger 自动设置了Content-Type: multipart/form-data并带上 boundary前端手动设置Content-Type时覆盖了 boundary导致服务端解析失败。解决前端用FormData时不要手动设Content-Type让浏览器自动生成。axios 会正确处理。6. 进阶把文件服务做成可复用的组件走到这里基本的上传下载已经能跑了。但如果项目里多个模块都要传文件每个 Controller 写一遍重复代码就不划算了。我一般会抽一个IFileStorageService把存储逻辑和接口层分开。public interface IFileStorageService { TaskStoredFile SaveAsync(IFormFile file, string category); TaskStream ReadAsync(string fileId); Task DeleteAsync(string fileId); } public class LocalFileStorageService : IFileStorageService { private readonly string _basePath; private static readonly HashSetstring Allowed new(StringComparer.OrdinalIgnoreCase) { .jpg, .png, .pdf, .docx, .xlsx, .zip }; public LocalFileStorageService(IWebHostEnvironment env) { _basePath Path.Combine(env.ContentRootPath, App_Data, files); if (!Directory.Exists(_basePath)) Directory.CreateDirectory(_basePath); } public async TaskStoredFile SaveAsync(IFormFile file, string category) { var ext Path.GetExtension(file.FileName); if (!Allowed.Contains(ext)) throw new InvalidOperationException($不支持的类型{ext}); var id ${category}/{DateTime.UtcNow:yyyyMM}/{Guid.NewGuid():N}{ext}; var fullPath Path.Combine(_basePath, id); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); using var stream new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true); await file.CopyToAsync(stream); return new StoredFile { Id id, OriginalName file.FileName, Size file.Length }; } public TaskStream ReadAsync(string fileId) { var fullPath Path.Combine(_basePath, fileId); if (!File.Exists(fullPath)) throw new FileNotFoundException(); return Task.FromResultStream(new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read)); } public Task DeleteAsync(string fileId) { var fullPath Path.Combine(_basePath, fileId); if (File.Exists(fullPath)) File.Delete(fullPath); return Task.CompletedTask; } }这样做的好处是存储路径和业务逻辑解耦以后要换成 MinIO 或云存储只需要换一个实现类。category参数用来分目录比如avatar、attachment避免所有文件堆在一个目录里。文件 ID 用category/年月/GUID.ext的格式既分散了目录又不会暴露原始文件名。注册到 DI 容器builder.Services.AddSingletonIFileStorageService, LocalFileStorageService();Controller 里注入IFileStorageService只负责参数校验和返回结果不碰文件系统细节。验证方法很简单写一个集成测试上传一个 50MB 的文件检查返回的 ID 能否正确读回内容是否一致。再上传一个.exe文件确认被拒绝。最后检查存储目录下没有可执行文件。我自己的习惯是每次做完文件服务都会用 OWASP ZAP 跑一遍上传接口的扫描重点看有没有路径穿越和未授权访问。这个步骤花不了十分钟但能挡掉大部分低级问题。文件上传下载看起来是 CRUD 里最简单的那一类但安全边界和参数细节比想象中多宁可前期多花半小时配好白名单和限制也别等线上出了事再回头补。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网