☰
.NET Core WebApi 文件上传下载:从接口能跑到敢上生产
2026/9/29 1:37:27 网站建设 项目流程

简介:这份资源面向.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 Task<IActionResult> 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.Configure<FormOptions>(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 HashSet<string> 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 Task<IActionResult> 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: bytes=0-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 { Task<StoredFile> SaveAsync(IFormFile file, string category); Task<Stream> ReadAsync(string fileId); Task DeleteAsync(string fileId); } public class LocalFileStorageService : IFileStorageService { private readonly string _basePath; private static readonly HashSet<string> 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 Task<StoredFile> 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 Task<Stream> ReadAsync(string fileId) { var fullPath = Path.Combine(_basePath, fileId); if (!File.Exists(fullPath)) throw new FileNotFoundException(); return Task.FromResult<Stream>(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.AddSingleton<IFileStorageService, LocalFileStorageService>();

Controller 里注入IFileStorageService,只负责参数校验和返回结果,不碰文件系统细节。

验证方法很简单:写一个集成测试,上传一个 50MB 的文件,检查返回的 ID 能否正确读回,内容是否一致。再上传一个.exe文件,确认被拒绝。最后检查存储目录下没有可执行文件。

我自己的习惯是,每次做完文件服务,都会用 OWASP ZAP 跑一遍上传接口的扫描,重点看有没有路径穿越和未授权访问。这个步骤花不了十分钟,但能挡掉大部分低级问题。文件上传下载看起来是 CRUD 里最简单的那一类,但安全边界和参数细节比想象中多,宁可前期多花半小时配好白名单和限制,也别等线上出了事再回头补。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询