☰
基于.NET Core WebApi的文件上传下载服务:从零搭建到生产避坑
2026/10/29 11:18:01 网站建设 项目流程

简介:这份资源面向正在学习或使用 .NET Core 开发 WebAPI 的初中级开发者,聚焦文件上传与下载服务的完整实现,涵盖 multipart/form-data 表单处理、IFormFile 接收、Content-Disposition 与 Content-Type 响应头设置、流式输出、权限验证、路径与文件名安全、异步与分块传输等常见工程要点。压缩包共 50 个文件,约 206KB,以 25 个 cs 源码文件为核心,配合 5 个 csproj 项目文件、9 个 json 配置、4 个 js 前端脚本及 Dockerfile、sln、readme 等,构成可直接运行的多项目示例。已有 1922 人学习下载。代码按上传服务、前端演示、客户端示例与中间件等模块组织,包含缩略图、负载均衡上传等中间件实现,便于读者对照理解控制器、模型、服务与配置的协作方式,并借鉴其中的安全校验与性能优化思路,快速搭建自己的文件服务。

1. 文件上传下载这件小事,为什么值得单独拆一个 WebApi 服务

后台管理系统里最容易被低估的模块,就是文件上传下载。前端一个el-upload拖拽框,后端一个IFormFile参数,看起来十分钟能写完,真到生产环境里大文件传一半断了、并发上传把内存打满、下载时中文文件名变成一串乱码、静态目录被当成公开网盘随便爬——这些坑一个都躲不掉。这个基于 .NET Core WebApi 的文件上传下载服务,干的就是把这类脏活从业务接口里剥离出来,做成一个独立、可复用、边界清晰的文件服务:上传走 multipart 表单,落盘带分目录和唯一文件名,下载支持流式响应和原始文件名还原,配套的控制器、服务层、配置项都拆得比较干净。适合正在做 .NET Core 后端、需要给业务系统补一块文件能力,或者想找一个能直接跑起来再改的 WebApi 骨架的开发者。下面按「它是什么 → 怎么跑起来 → 核心代码怎么写的 → 怎么改」的顺序拆。

2. 项目定位与技术栈:一个只干文件活的 WebApi 骨架

2.1 它到底解决什么问题

很多业务项目里,文件上传是散落在各个 Controller 里的:用户头像一个接口、合同附件一个接口、导入 Excel 又一个接口,每个都自己拼路径、自己判断扩展名、自己处理异常,最后磁盘上是一堆命名混乱的文件,谁也说不清哪个文件属于谁。这个项目的思路是把文件能力收敛成一个独立服务,对外只暴露两类动作:上传和下载。上传负责接收、校验、重命名、落盘、返回可访问的标识;下载负责根据标识找到物理文件、以流的方式写回响应、还原原始文件名。业务侧只需要拿到上传返回的标识存进自己的表,后面拼下载地址就行,不用关心文件到底躺在哪个盘、哪层目录。

这种拆分带来的直接好处是可替换。今天文件存本地磁盘,明天要换成对象存储,只改服务层实现,Controller 和业务代码不动。另一个好处是安全边界清楚:文件目录不挂静态资源中间件,所有访问都必须经过下载接口,权限校验、防盗链、下载计数都有地方插。这也是我一般建议的做法——文件目录永远不要直接暴露成静态目录,否则等于给自己开了一个不受控的公开网盘。

2.2 技术栈与依赖清单

项目是标准的 ASP.NET Core WebApi 结构,没有引入数据库,文件元信息靠目录结构和文件名承载,属于「轻量到可以直接塞进任何项目」的定位。核心依赖如下:

组件作用说明
ASP.NET Core WebApi提供 HTTP 接口宿主控制器 + 依赖注入 + 中间件管道
IFormFile / IFormFileCollection接收 multipart 上传框架内置,无需额外包
IWebHostEnvironment获取 ContentRootPath用来定位上传根目录
Microsoft.Extensions.Options绑定上传配置限制大小、允许扩展名等
Swagger(Swashbuckle)接口调试上传接口在 Swagger 里可直接选文件测试

没有数据库这一点要提前说清楚:文件服务本身不存元数据,谁上传的、属于哪个业务,由调用方自己记录。这不是偷懒,而是让服务保持无状态、可水平扩展的前提。如果你需要文件归属、软删除、秒传这些能力,那是二次开发要补的部分,后面第 5 章会讲怎么加。

2.3 目录结构长什么样

一个能跑的文件服务,目录结构基本是固定的几层,理解了这个结构,后面看代码就不会迷路:

FileService.Api/ ├── Controllers/ │ └── FileController.cs # 上传、下载两个入口 ├── Services/ │ ├── IFileService.cs # 文件服务接口 │ └── LocalFileService.cs # 本地磁盘实现 ├── Models/ │ ├── UploadResult.cs # 上传返回结构 │ └── FileOptions.cs # 上传配置项 ├── wwwroot/ │ └── uploads/ # 上传根目录(运行时创建) ├── appsettings.json # 文件相关配置 └── Program.cs # 服务注册与中间件

wwwroot/uploads只是物理存放位置,注意它并没有通过UseStaticFiles暴露出去,访问必须走FileController的下载接口。这一点在部署时经常被忽略,有人图省事直接开静态目录,结果上传目录被目录遍历扫了个遍。

3. 跑起来:从零到能上传能下载的完整步骤

3.1 环境准备与项目创建

先确认本机装了 .NET SDK,版本用 6.0 及以上都行,命令里把版本号换成你本地的即可:

# 确认 SDK 版本,6.0 / 7.0 / 8.0 均可 dotnet --version # 新建 WebApi 项目(如果拿到的是现成源码,跳过这步) dotnet new webapi -n FileService.Api cd FileService.Api # 还原依赖并启动 dotnet restore dotnet run

启动后默认监听http://localhost:5000或https://localhost:5001,控制台会打印实际端口,以它为准。如果拿到的是完整源码包,直接dotnet restore再dotnet run就行,不用重新建项目。这里有个血泪经验:不同 SDK 版本生成的Program.cs顶层语句写法略有差异,6.0 是Main方法,8.0 是顶级语句,混着抄代码会编译不过,先看一眼你项目里的Program.cs长什么样再动手。

3.2 配置上传根目录和大小限制

文件服务的第一件事是把「文件存哪、能存多大、允许什么类型」变成配置,而不是硬编码在代码里。在appsettings.json里加一段:

{ "FileOptions": { "RootPath": "wwwroot/uploads", "MaxSizeMB": 50, "AllowedExtensions": [ ".jpg", ".png", ".pdf", ".docx", ".xlsx", ".zip" ], "AllowOverwrite": false } }

RootPath是相对内容根目录的路径,部署到容器里时可以用环境变量覆盖成挂载卷路径;MaxSizeMB是单文件上限,注意它和 Kestrel 的请求体上限是两回事,后者默认约 30MB,超过会直接 413,这个坑第 4 章单独讲;AllowedExtensions是白名单,空数组表示不限制,但生产环境不建议留空;AllowOverwrite控制同名文件是否覆盖,默认 false,靠唯一文件名避免冲突。

对应的配置类:

public class FileOptions { public const string SectionName = "FileOptions"; public string RootPath { get; set; } = "wwwroot/uploads"; public int MaxSizeMB { get; set; } = 50; public string[] AllowedExtensions { get; set; } = Array.Empty<string>(); public bool AllowOverwrite { get; set; } = false; }

SectionName常量是为了注册时和读取时用同一个字符串,避免手写"FileOptions"拼错导致配置读不到、全部走默认值——这种问题不报错,只是行为不对,排查起来很费时间。

3.3 服务注册与中间件顺序

在Program.cs里把配置、服务、控制器串起来:

var builder = WebApplication.CreateBuilder(args); // 绑定文件配置,后续通过 IOptions<FileOptions> 注入 builder.Services.Configure<FileOptions>( builder.Configuration.GetSection(FileOptions.SectionName)); // 注册文件服务,Scoped 足够,内部无状态 builder.Services.AddScoped<IFileService, LocalFileService>(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } // 注意:这里没有 UseStaticFiles,上传目录不对外暴露 app.UseAuthorization(); app.MapControllers(); app.Run();

几个点值得说明。Configure绑定的是配置节,注入时用IOptions<FileOptions>.Value取;LocalFileService注册成 Scoped,虽然它本身没有状态,但保持和请求生命周期一致,将来要注入DbContext记元数据时不用改注册。最关键的是没有app.UseStaticFiles()——上传目录只能通过下载接口访问,这是安全底线。如果你确实需要前端直接展示图片,正确做法是单独开一个受控的图片读取接口,而不是把整个上传目录挂成静态资源。

3.4 用 Swagger 和 curl 各验证一遍

启动后打开/swagger,能看到POST /api/file/upload和GET /api/file/download两个接口。上传接口在 Swagger 里会渲染成文件选择框,选一个文件点 Execute 即可。命令行验证更贴近真实调用:

# 上传,-F 指定 multipart 表单字段名,要和接口参数名一致 curl -X POST http://localhost:5000/api/file/upload \ -F "file=@./test.pdf" # 假设返回 { "fileName": "20240512_a1b2c3d4.pdf", "originalName": "test.pdf" } # 下载,用返回的 fileName 作为参数 curl -OJ http://localhost:5000/api/file/download?fileName=20240512_a1b2c3d4.pdf

-OJ让 curl 按响应头里的文件名保存,正好可以验证下载接口有没有正确设置Content-Disposition。如果下载下来文件名是乱码或者变成download,说明文件名编码没处理对,回到第 3.5 节看下载实现。

3.5 最小可用的上传下载闭环

跑通的标准不是「接口返回 200」,而是三件事同时成立:文件真的落到了wwwroot/uploads下、返回的标识能再次下载回来、下载下来的文件内容和原文件一致。建议第一次跑完手动做一遍这个校验:

# 对比原文件和下载文件的哈希,一致才算闭环 sha256sum ./test.pdf sha256sum ./downloaded.pdf

两个哈希一样,说明上传落盘和下载流式输出都没有截断或编码问题。这一步看着多余,但大文件场景下最能暴露问题——比如上传时用了CopyToAsync但没await,小文件碰巧写完,大文件就只剩半截。

4. 核心代码拆解:上传落盘与流式下载的完整链路

4.1 上传接口:参数校验与唯一文件名生成

上传入口在 Controller 里,职责很薄:接住IFormFile,做基础校验,转给服务层。真正的落盘逻辑放在LocalFileService,这样 Controller 不依赖磁盘细节。

[ApiController] [Route("api/[controller]")] public class FileController : ControllerBase { private readonly IFileService _fileService; public FileController(IFileService fileService) { _fileService = fileService; } [HttpPost("upload")] [RequestSizeLimit(100 * 1024 * 1024)] // 单请求体上限,需大于业务上限 public async Task<ActionResult<UploadResult>> Upload( IFormFile file, CancellationToken ct) { if (file is null || file.Length == 0) return BadRequest("文件为空"); var result = await _fileService.SaveAsync(file, ct); return Ok(result); } }

[RequestSizeLimit]是必须显式加的,它控制的是这个 action 允许的请求体大小,和FileOptions.MaxSizeMB形成两道闸:前者防超大请求打进来,后者做业务级校验。两个值要匹配,RequestSizeLimit略大于业务上限留点余量。CancellationToken一路透传到写文件,客户端断开时能及时停止写入,避免半截文件占着磁盘。

服务层的保存实现:

public async Task<UploadResult> SaveAsync(IFormFile file, CancellationToken ct) { var opt = _options.Value; // 1. 扩展名白名单校验,取小写比较 var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (opt.AllowedExtensions.Length > 0 && !opt.AllowedExtensions.Contains(ext)) throw new InvalidOperationException($"不允许的文件类型:{ext}"); // 2. 业务级大小校验 if (file.Length > opt.MaxSizeMB * 1024L * 1024L) throw new InvalidOperationException($"文件超过 {opt.MaxSizeMB}MB"); // 3. 按日期分目录,避免单目录文件过多 var dateDir = DateTime.Now.ToString("yyyyMMdd"); var dir = Path.Combine(_env.ContentRootPath, opt.RootPath, dateDir); Directory.CreateDirectory(dir); // 4. 唯一文件名:时间戳 + GUID,保留扩展名 var storedName = $"{DateTime.Now:HHmmss}_{Guid.NewGuid():N}{ext}"; var fullPath = Path.Combine(dir, storedName); // 5. 流式写入,不整块读进内存 await using var stream = new FileStream( fullPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true); await file.CopyToAsync(stream, ct); return new UploadResult { FileName = $"{dateDir}/{storedName}", // 带相对路径,下载时用 OriginalName = file.FileName, Size = file.Length }; }

逐段说。扩展名校验用ToLowerInvariant而不是ToLower,避免土耳其语等区域设置下I的大小写转换出问题,这是 .NET 里一个经典玄学坑。分目录用yyyyMMdd,单目录文件数控制在几千以内,文件系统查找性能才稳。唯一文件名用「时分秒 + GUID」,既保证不冲突,又让文件名带一点时间信息方便人工排查,扩展名保留是为了下载时能正确推断 MIME。FileMode.CreateNew配合 GUID 基本不会撞名,万一撞了会抛异常而不是静默覆盖,比Create安全。缓冲区 81920 字节是 .NET 流复制的默认值,不用改。

UploadResult.FileName返回的是日期目录/文件名这种相对路径,下载时直接拿它拼物理路径,省去再查一次目录。这里要注意路径拼接必须用Path.Combine,并且下载前要校验拼接结果仍在根目录内,防止../穿越,第 4.3 节会写这个校验。

4.2 下载接口:流式响应与中文文件名还原

下载的核心是两件事:用FileStreamResult流式输出,不把整个文件读进内存;正确设置Content-Disposition,让浏览器还原原始文件名,中文不乱码。

[HttpGet("download")] public async Task<IActionResult> Download( [FromQuery] string fileName, CancellationToken ct) { var (stream, contentType, downloadName) = await _fileService.OpenReadAsync(fileName, ct); // 第二个参数是浏览器保存时的文件名,框架内部会做 RFC 5987 编码 return File(stream, contentType, downloadName, enableRangeProcessing: true); }

File(...)这个重载的第三个参数fileDownloadName是关键,ASP.NET Core 会自动把中文文件名编码成filename*=UTF-8''...的形式,浏览器能正确识别。如果你自己手拼Content-Disposition头,很容易漏掉编码,中文就变乱码。enableRangeProcessing: true打开断点续传和视频拖动支持,对大文件下载很实用,代价几乎为零。

服务层的打开逻辑:

public Task<(Stream, string, string)> OpenReadAsync( string fileName, CancellationToken ct) { var opt = _options.Value; var root = Path.GetFullPath( Path.Combine(_env.ContentRootPath, opt.RootPath)); // 关键:规范化后校验仍在根目录内,防目录穿越 var fullPath = Path.GetFullPath(Path.Combine(root, fileName)); if (!fullPath.StartsWith(root + Path.DirectorySeparatorChar)) throw new UnauthorizedAccessException("非法路径"); if (!File.Exists(fullPath)) throw new FileNotFoundException("文件不存在"); var stream = new FileStream( fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 81920, useAsync: true); var contentType = GetContentType(Path.GetExtension(fullPath)); // 原始文件名这里用存储名兜底,真实项目应查元数据表 var downloadName = Path.GetFileName(fullPath); return Task.FromResult<(Stream, string, string)>( (stream, contentType, downloadName)); }

目录穿越校验是这段代码里最不能省的三行。Path.GetFullPath会把../解析掉,再判断结果是否以根目录开头,能挡住fileName=../../appsettings.json这类攻击。注意比较时根目录后面要补一个分隔符,否则uploads-evil这种同前缀目录会被误判为合法。FileShare.Read允许多个下载同时读同一个文件,上传时用的是FileShare.None,两者配合保证写的时候没人读、读的时候不阻塞其他读。

GetContentType是个简单的扩展名到 MIME 的映射,常见做法是维护一个字典,命中不了就返回application/octet-stream让浏览器当二进制下载。真实项目里原始文件名应该存在元数据表里,这里用存储名兜底只是让 Demo 能跑通。

4.3 异常处理与统一返回

文件服务的异常有两类:业务异常(类型不允许、超限、文件不存在)和系统异常(磁盘满、权限不足)。前者应该返回 4xx 带明确信息,后者返回 500 并记日志。用一个异常中间件统一兜住:

app.UseExceptionHandler(errApp => { errApp.Run(async ctx => { var ex = ctx.Features .Get<IExceptionHandlerFeature>()?.Error; var (status, msg) = ex switch { InvalidOperationException e => (400, e.Message), FileNotFoundException e => (404, e.Message), UnauthorizedAccessException => (403, "无权访问"), _ => (500, "服务器内部错误") }; ctx.Response.StatusCode = status; await ctx.Response.WriteAsJsonAsync(new { code = status, msg }); }); });

这样 Controller 里不用到处 try-catch,服务层直接抛语义明确的异常,中间件负责翻译成 HTTP 状态码。注意 500 分支不要把ex.Message返回给前端,磁盘路径、堆栈这类信息泄露出去是安全隐患,日志里记全就行。

5. 避坑与排查:文件服务上线前必须过的五道关

5.1 大文件上传报 413,配置改了也没用

现象:上传 40MB 文件,接口直接返回 413,FileOptions.MaxSizeMB明明设的 50。原因:Kestrel 的请求体上限默认约 30MB,它在进入 action 之前就把请求拒了,[RequestSizeLimit]和业务校验都还没轮到。解决:在Program.cs里调大 Kestrel 限制,或者针对单个 action 用[RequestSizeLimit]覆盖,两者取较大值。如果前面还有反向代理,代理层也有自己的 body 大小限制,要一起调,否则改了代码还是 413。

builder.WebHost.ConfigureKestrel(o => { o.Limits.MaxRequestBodySize = 200 * 1024 * 1024; });

5.2 下载中文文件名变乱码或变成 download

现象:上传测试文档.pdf,下载下来文件名是一串%E6%B5%8B...或者干脆叫download。原因:手写了Content-Disposition头但没做 RFC 5987 编码,或者用了File(stream, contentType)这个不带文件名的重载。解决:用File(stream, contentType, fileDownloadName)三参数重载,让框架处理编码;如果必须手写头,用ContentDispositionHeaderValue构造,别拼字符串。

5.3 上传成功但文件是 0 字节或半截

现象:接口返回 200,磁盘上有文件,但大小不对。原因:CopyToAsync没await,或者FileStream没包在await using里,方法提前返回把流关了。解决:所有异步 IO 一律await,流用await using确保释放;上传完成后可以比对file.Length和落盘文件长度,不一致直接抛异常,别让坏文件进库。

5.4 并发上传同名文件互相覆盖

现象:两个人同时传report.pdf,后传的把先传的覆盖了。原因:用了原始文件名落盘,或者用了FileMode.Create。解决:落盘名一律用 GUID 或雪花 ID 重新生成,原始文件名只作为元数据保存;打开文件用FileMode.CreateNew,撞名抛异常而不是覆盖。这个改动很小,但能省掉后面「文件怎么被换了」的扯皮。

5.5 上传目录被直接访问,文件裸奔

现象:安全扫描报「目录可遍历」,或者有人直接拼http://域名/uploads/xxx.pdf就能下载。原因:开了UseStaticFiles且把上传目录放在了wwwroot下。解决:上传目录移出wwwroot,或者保留位置但绝不注册静态文件中间件;所有访问走下载接口,在接口里做鉴权。已经上线的项目如果改不动目录,至少加一条中间件拦截/uploads路径的直连请求。

6. 二次开发:把本地文件服务改成能落库、能换存储的版本

这个 Demo 能跑,但离生产还有距离,最常见的三个改造方向是:文件元数据落库、存储介质可切换、上传接口支持分片。下面说改法和验证方式。

先说元数据落库。现在下载靠fileName反推物理路径,原始文件名丢了,也没法做归属和软删除。改造思路是加一张FileRecord表,字段至少包含Id、StoredPath、OriginalName、ContentType、Size、UploaderId、CreatedAt、IsDeleted。上传成功后插一条记录,返回Id给业务方;下载接口参数从fileName换成id,先查库拿到StoredPath再开流。这样原始文件名、归属、删除状态都有了着落,将来做「我的文件列表」也是查这张表。验证方法:上传后查库确认记录存在,删掉物理文件再下载应返回 404 而不是 500。

再说存储切换。IFileService接口就是为这个准备的,把SaveAsync和OpenReadAsync抽成接口方法,本地实现叫LocalFileService,对象存储实现叫OssFileService,在Program.cs里按配置项决定注册哪个:

var provider = builder.Configuration["FileOptions:Provider"] ?? "Local"; if (provider == "Oss") builder.Services.AddScoped<IFileService, OssFileService>(); else builder.Services.AddScoped<IFileService, LocalFileService>();

接口方法签名要设计得对两种存储都友好:SaveAsync返回存储路径和访问标识,OpenReadAsync返回Stream,对象存储的 SDK 一般都能拿到可读流,实在拿不到就返回一个重定向 URL,让 Controller 判断是流还是跳转。验证方法:同一套接口测试用例跑两遍,只改配置不改代码,两边行为一致才算抽象成功。

最后是分片上传。大文件场景下整包上传体验很差,断了要重传。改造思路是加三个接口:初始化上传(返回uploadId)、上传分片(带uploadId和chunkIndex)、合并分片(按序拼接后走正常落盘)。分片临时文件放独立目录,合并成功后清理。这块工作量不小,建议先确认业务真的需要再动,别为了技术完整性给自己挖坑。

我自己的习惯是,每次给文件服务加新能力,都先把「上传 → 落库 → 下载 → 删除」这条主链路用 curl 跑一遍,再动代码。有一次图快直接改了下载参数名,前端没同步,线上图片全裂了,排查了半小时才发现是参数对不上。从那以后我每次改接口签名都强制走一遍这条链路,确认四个动作都通再提交。希望帮到你。

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

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

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

立即咨询