资讯动态

.NET Core WebApi 文件服务:上传下载、分片续传与Range

发布时间:2026/11/6 6:46:40 来源:尧图企业网站定制
简介这是一份面向.NET Core WebApi开发者与学习者的文件上传下载服务示例包围绕在WebApi项目中实现文件传输这一常见需求提供可参考的工程结构与实现思路。内容涉及表单文件上传、文件接收处理、下载响应头设置与流式输出以及权限校验、路径和文件名安全、类型限制、异步与分块传输、缓存和日志记录等实践点适合具备一定C#与ASP.NET Core基础、希望补齐文件服务能力的后端人员。压缩包共50个文件以25个cs源码、9个json配置、5个csproj项目文件为主辅以少量js、html、md说明和Dockerfile等约206KB。已有1925人学习下载。读者可从中了解控制器、中间件、配置与服务分层组织方式并借鉴上传服务、前端演示、客户端示例及缩略图、负载均衡中间件等模块的落地做法用于搭建或改造自己的文件服务。1. 上传下载这件事为什么最后都收敛到一个文件服务前阵子帮一个团队看后台系统的代码他们的附件上传散落在四个 Controller 里每个都自己拼路径、自己判断后缀磁盘上于是出现了三层嵌套的日期目录还有一堆叫1 (2).pdf的文件。后来他们把这部分抽出来做成一个独立的 .NET Core WebApi 文件服务上传、下载、删除三条链路统一入口前端只认一个 fileId。这种拆法适合谁适合手里有后台管理、工单、内容发布这类系统的团队——附件量不大但要得急不想为了几个文件去接一整套对象存储 SDK又不愿意把二进制塞进业务库。这套代码本身不复杂麻烦全在边界上大文件、并发、断点续传、中文文件名、访问权限。下面按我拆这套 .NET Core WebApi 文件服务的顺序讲。2. 工程骨架从 Program.cs 到存储抽象的目录切分2.1 目录结构决定后面好不好改一套文件服务能不能二次开发八成看目录切得对不对。我见过把所有逻辑塞进一个FilesController.cs的写法三百多行上传、校验、落盘、拼下载头全在里面后来要换成对象存储改了两天。比较省心的切法是把「协议层」和「存储层」分开Controller 只负责 HTTP 语义状态码、请求体、返回体存储层只负责把Stream变成fileId再变回来中间夹一个校验器。FileService/ ├── Controllers/ │ ├── FilesController.cs # 上传、下载、删除的统一入口 │ └── ChunkController.cs # 分片初始化、上传分片、合并 ├── Services/ │ ├── IFileStorage.cs # 存储抽象换实现不动业务代码 │ ├── LocalFileStorage.cs # 落本地磁盘的默认实现 │ └── FileValidator.cs # 扩展名白名单、大小、魔数校验 ├── Models/ │ ├── StoredFileInfo.cs # fileId 原始文件名 contentType 长度 │ └── ApiResult.cs # 统一返回结构 ├── Options/ │ └── FileStorageOptions.cs # RootPath / MaxSize / AllowedExtensions ├── Middlewares/ │ └── ExceptionMiddleware.cs # 兜底异常避免把堆栈返回给前端 ├── appsettings.json └── Program.cs这个结构里最关键的一条线是IFileStorage之外的所有代码都不得出现Path.Combine、Directory.CreateDirectory这种磁盘 API。做到这一点后面从本地盘切到对象存储改动范围就只有新增一个实现类加一行注册。2.2 Program.cs 里真正要改的只有三处Program.cs大部分内容是模板生成的但文件服务有三处必须手动配少一处就会在上线后翻车请求体上限Kestrel 一层、FormOptions 一层、存储实现的注册方式、异常中间件的顺序。var builder WebApplication.CreateBuilder(args); builder.Services.ConfigureFileStorageOptions( builder.Configuration.GetSection(FileStorage)); // 第一处multipart 表单的体积上限。 // 默认 128MB 左右超了会抛 InvalidDataException前端看到的是 500 而不是 413 builder.Services.ConfigureFormOptions(o { o.MultipartBodyLengthLimit 200L * 1024 * 1024; o.ValueLengthLimit 1024 * 1024; // 普通字段别跟着放大防止被塞垃圾 }); // 第二处Kestrel 自己的请求体限制和上面是两个独立开关 builder.WebHost.ConfigureKestrel(o { o.Limits.MaxRequestBodySize 200L * 1024 * 1024; }); // 存储实现注册成单例它只持有根路径无状态没必要每次请求 new builder.Services.AddSingletonIFileStorage, LocalFileStorage(); builder.Services.AddScopedFileValidator(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app builder.Build(); // 第三处异常中间件必须在管道最前面 // 否则 Swagger、静态文件这些环节抛的错它兜不住 app.UseMiddlewareExceptionMiddleware(); app.UseSwagger(); app.UseSwaggerUI(); app.MapControllers(); app.Run();参数含义需要说清楚MultipartBodyLengthLimit管的是 MVC 解析multipart/form-data时单个请求体的总量MaxRequestBodySize管的是 Kestrel 在更底层读 socket 时的截断阈值。两者取小生效只改一个等于没改。200MB 这个值按业务定我给的建议是「业务最大允许文件的 1.2 倍」留出表单其他字段的余量。ValueLengthLimit容易被忽略它限制的是表单里非文件字段的大小放太大等于给攻击者留了一个用超长字符串耗内存的口子。注册成Singleton有个前提实现类里不能有请求级状态。LocalFileStorage只持有根路径字符串是安全的如果哪天要注入IHttpContextAccessor去读租户 ID就得改成Scoped这是换存储时最容易踩的一脚。2.3 存储抽象长什么样接口设计得对不对就看一件事调用方拿到的是不是Stream。如果接口签名是Taskbyte[] ReadAsync(...)那大文件下载必然 OOM后面再怎么优化都是打补丁。public interface IFileStorage { /// summary写入文件返回对外暴露的 fileId/summary Taskstring SaveAsync(Stream source, string originalName, string contentType, CancellationToken ct default); /// summary按 fileId 打开只读流由调用方负责释放/summary TaskStream OpenReadAsync(string fileId, CancellationToken ct default); TaskStoredFileInfo? GetInfoAsync(string fileId, CancellationToken ct default); Taskbool DeleteAsync(string fileId, CancellationToken ct default); }四个方法覆盖了增删查读OpenReadAsync返回Stream而不是byte[]是刻意的下载链路最终要把流交给FileStreamResult让它边读边写响应体全程只有 80KB 左右的缓冲区在内存里。LocalFileStorage的落盘实现里有两个细节值得抄public async Taskstring SaveAsync(Stream source, string originalName, string contentType, CancellationToken ct default) { // 对外 fileId 用 GUID和磁盘真实路径解耦 // 一是杜绝同名覆盖二是原始文件名不参与拼路径天然防住 ../ 穿越 var fileId Guid.NewGuid().ToString(N); // 取前两位做子目录单目录文件数上万之后某些文件系统的列目录会明显变慢 var dir Path.Combine(_root, fileId[..2]); Directory.CreateDirectory(dir); var fullPath Path.Combine(dir, fileId); // useAsync:true 让读写走异步 IO别用同步 FileStream 阻塞线程池 await using var fs new FileStream(fullPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true); await source.CopyToAsync(fs, 81920, ct); // 原文件名、contentType 单独存一份元数据下载时拼 Content-Disposition 要用 var info new StoredFileInfo(fileId, originalName, contentType, fs.Length); await File.WriteAllTextAsync(fullPath .meta, JsonSerializer.Serialize(info), ct); return fileId; }FileMode.CreateNew而不是Create是因为 GUID 撞车概率虽低但不是零CreateNew会在文件已存在时直接抛异常把问题暴露在第一现场而不是悄悄覆盖别人的文件。缓冲区 81920 字节80KB是 .NET 里CopyToAsync的常用默认值对本地磁盘够用如果后面换成走网络的对象存储可以调到 256KB 减少往返次数。fs.Length在await using作用域内取此时数据已经 flush 到流里拿到的是准确长度别挪到using外面去取。3. 上传链路一个 IFormFile 从请求体到磁盘的完整路径3.1 控制器只做三件事收、验、转一个健康的 Controller action 不该超过 30 行。它要做的事只有三件把IFormFile拿到手、调用校验器判断能不能收、把流交给存储层。剩下所有东西——日志、重试、元数据落库——都该在别的层。[HttpPost(upload)] [RequestSizeLimit(200L * 1024 * 1024)] public async TaskIActionResult UploadAsync( [FromForm] IFormFile file, [FromServices] IFileStorage storage, [FromServices] FileValidator validator, CancellationToken ct) { if (file is null || file.Length 0) return BadRequest(new { code 40001, message 空文件 }); // 校验必须放在服务端。前端 input accept 只是体验优化随手改一下就绕过了 var check await validator.ValidateAsync(file, ct); if (!check.Passed) return BadRequest(new { code 40002, message check.Reason }); // OpenReadStream 拿到的是底层请求体的只读视图不额外拷贝一份到内存 await using var stream file.OpenReadStream(); var fileId await storage.SaveAsync(stream, file.FileName, file.ContentType, ct); return Ok(new { code 0, fileId, name file.FileName, size file.Length }); }三个参数的作用分别是[FromForm] IFormFile file绑定表单里的文件字段字段名必须和前端FormData.append(file, blob)里的名字一致不一致会绑定成 null 且不报错[FromServices]是显式注入比构造函数注入更直观地表明「这个 action 依赖谁」CancellationToken挂在 action 参数上用户中途断开浏览器时能顺着它把落盘链路一起取消省掉一次无意义的写盘。file.Length在这里是可信的因为它来自 multipart 头部声明的长度加上框架的解析结果但注意它反映的是「已接收」的字节数在超大文件场景下并不等于完整文件大小真正的完整性要靠分片合并的数量校验。3.2 流式落盘与三个体积限制的关系很多人第一次写上传会写成var bytes new byte[file.Length]; await file.OpenReadStream().ReadAsync(bytes);本地测 5MB 的图片毫无问题上线遇到 200MB 的安装包直接触发内存告警。IFormFile在请求体小于阈值默认 64KB时会缓冲在内存里超过阈值才落到临时文件所以file.OpenReadStream()拿到的可能是内存流也可能是临时文件流——不管哪种直接CopyToAsync到目标流都是最省内存的路径。校验器这一层值得单独写因为它是唯一能挡住恶意文件的地方public async TaskValidationResult ValidateAsync(IFormFile file, CancellationToken ct) { var ext Path.GetExtension(file.FileName).ToLowerInvariant(); if (!_options.AllowedExtensions.Contains(ext)) return ValidationResult.Fail($不支持的类型 {ext}); if (file.Length _options.MaxSize) return ValidationResult.Fail(文件超出大小限制); // 只信扩展名是不够的把 .exe 改成 .jpg 一样能过。 // 读前 8 个字节比对魔数pdf 是 25 50 44 46png 是 89 50 4E 47 var header new byte[8]; await using var s file.OpenReadStream(); var read await s.ReadAsync(header, ct); if (read 4 || !_signatures.Any(sig header.AsSpan(0, sig.Length).SequenceEqual(sig))) return ValidationResult.Fail(文件内容与类型不符); return ValidationResult.Ok(); }注意file.OpenReadStream()在这里被调用了第二次第一次在 Controller 里。这是允许的但第二次读要从头开始必须在读完 header 后不要复用这个流让 Controller 重新开一个。这个细节如果不注意会出现「校验通过但存进去的文件少了 8 个字节」的诡异现象排查起来相当费劲。3.3 分片上传三个接口撑起断点续传超过 100MB 的文件单请求上传的体验就很差了——断网要重传、进度条也不准。分片上传的接口设计比想象中简单三个端点初始化、传分片、合并。[HttpPost(chunk/init)] public IActionResult InitChunk([FromBody] InitChunkRequest req) { // 会话标识用 文件哈希 分片总数同一个文件重传时能命中已有目录 var uploadId ${req.FileHash}_{req.TotalChunks}; var dir Path.Combine(_chunkRoot, uploadId); Directory.CreateDirectory(dir); // 把已经存在的分片序号返回给前端前端跳过这些断点续传就成立了 var uploaded Directory.GetFiles(dir) .Select(p int.Parse(Path.GetFileName(p))) .OrderBy(i i) .ToList(); return Ok(new { uploadId, uploaded }); } [HttpPost(chunk/{uploadId}/{index:int})] [DisableRequestSizeLimit] // 单个分片不需要总大小限制 public async TaskIActionResult UploadChunkAsync( string uploadId, int index, IFormFile chunk, CancellationToken ct) { var dir Path.Combine(_chunkRoot, uploadId); if (!Directory.Exists(dir)) return NotFound(会话不存在或已过期); // 序号补零到 6 位保证字符串排序和数字排序一致合并时才能直接 OrderBy var path Path.Combine(dir, index.ToString(D6)); var tmp path .tmp; // 先写临时文件再改名中途断开时目录里不会出现半截分片 // 下次 init 拿到 uploaded 列表也不会误报「这片已经有了」 await using (var fs new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true)) { await chunk.CopyToAsync(fs, ct); } File.Move(tmp, path, overwrite: true); return Ok(); }合并是分片链路里最容易写错的一步[HttpPost(chunk/{uploadId}/merge)] public async TaskIActionResult MergeAsync(string uploadId, [FromBody] MergeRequest req) { var dir Path.Combine(_chunkRoot, uploadId); var parts Directory.GetFiles(dir, *.??????).OrderBy(p p, StringComparer.Ordinal).ToList(); // 少一片都不能合否则用户拿到的是个损坏文件比直接失败更糟 if (parts.Count ! req.TotalChunks) return BadRequest(new { message $分片不完整已有 {parts.Count}/{req.TotalChunks} }); var fileId Guid.NewGuid().ToString(N); var target Path.Combine(_root, fileId[..2], fileId); Directory.CreateDirectory(Path.GetDirectoryName(target)!); await using (var output new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true)) { foreach (var part in parts) { await using var input new FileStream(part, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true); await input.CopyToAsync(output); } } // 合并完立刻清分片。这一步漏了磁盘会在几周内被吃满 // 而且是那种「明明没人传大文件却每天都在涨」的黑匣子式问题 Directory.Delete(dir, recursive: true); return Ok(new { fileId }); }Directory.GetFiles的通配符*.??????只匹配文件名恰好 6 位的文件正好把.tmp残留排除在外。StringComparer.Ordinal保证按字节序排补零之后序号顺序就是字典序不会出现 10 排在 2 前面的经典问题。合并本身是纯 IO 拼接不涉及解密和转码200MB 的文件在普通 SSD 上大概两三百毫秒如果业务上有更大量级就得考虑直接返回一个「合并中」的任务 ID让后台异步合并。4. 下载链路Range 请求、断点续传与内容协商4.1 用框架的 Range 支持别自己造下载这块最常见的错觉是「return File(bytes, contentType, name)就行了」。小文件确实行但超过几百 MB 就开始出问题浏览器无法拖进度条、下载器无法续传、视频拖动直接重新加载。根因是没有返回Accept-Ranges: bytes和206 Partial Content。ASP.NET Core 的FileStreamResult自带 Range 处理只要打开一个开关[HttpGet({fileId})] public async TaskIActionResult DownloadAsync(string fileId, CancellationToken ct) { var info await _storage.GetInfoAsync(fileId, ct); if (info is null) return NotFound(new { code 40401, message 文件不存在 }); var stream await _storage.OpenReadAsync(fileId, ct); // enableRangeProcessing:true 会让框架解析请求头 Range // 命中时自动返回 206 Content-Range不命中返回 200 全量 return File(stream, info.ContentType, info.FileName, enableRangeProcessing: true); }参数要说明白第一个参数传Stream而不是byte[]框架会用它构造FileStreamResult边读边写响应体info.ContentType如果元数据里存的是空值或者application/octet-stream浏览器会一律当附件下载想让图片直接预览就得在存的时候把image/jpeg这类真实类型记下来enableRangeProcessing是那个开关默认false很多人以为框架默认开了结果下载器一直显示「服务器不支持断点续传」。这里有个隐藏前提流必须支持Seek。FileStream天然支持所以本地存储没问题一旦换成对象存储返回的NetworkStream或者某些包装流Seek会直接抛NotSupportedException。这是把本地存储换成对象存储时最容易翻车的一处后面第 6 章会展开。4.2 中文文件名Content-Disposition 的两个字段都要写文件名里有中文的时候如果只写filename报告.pdf部分客户端会拿到乱码或者被截断成pdf。原因是 HTTP 头按 ISO-8859-1 编码中文塞不进filename得用 RFC 5987 定义的filename*。[HttpGet({fileId}/named)] public async TaskIActionResult DownloadNamedAsync(string fileId, CancellationToken ct) { var info await _storage.GetInfoAsync(fileId, ct); if (info is null) return NotFound(); // 百分号编码用于 filename*UTF-8 前缀表明编码方式 var encoded Uri.EscapeDataString(info.FileName); // filename 留一个 ASCII 兜底名老客户端只认这个 // filename* 给新客户端两者同时存在时后者优先 Response.Headers.ContentDisposition $attachment; filename\download{Path.GetExtension(info.FileName)}\; filename*UTF-8{encoded}; var stream await _storage.OpenReadAsync(fileId, ct); return File(stream, info.ContentType, enableRangeProcessing: true); }Uri.EscapeDataString会把空格编成%20而不是这点很重要——在Content-Disposition里不会被解码成空格用户会看到文件名里带着加号。另外注意这个 action 里File(...)没有传第三个参数文件名因为头已经手动写过了再传一次框架会覆盖掉等于白写。4.3 什么时候需要手写 Range 解析绝大多数场景用框架的开关就够了。但有两种情况得自己算一是存储层返回的流不可Seek需要先从元数据里拿到文件长度、自己定位二是要做「只下载文件某一段」的业务比如从一个大日志包里抠出最后 1MB 看尾部。手写版本的核心是解析Range: bytesstart-end返回206并带上Content-Range: bytes start-end/total[HttpGet({fileId}/range)] public async TaskIActionResult DownloadRangeAsync(string fileId, CancellationToken ct) { var info await _storage.GetInfoAsync(fileId, ct); if (info is null) return NotFound(); var rangeHeader Request.Headers.Range.ToString(); if (string.IsNullOrEmpty(rangeHeader)) return File(await _storage.OpenReadAsync(fileId, ct), info.ContentType); // 只处理单区间格式形如 bytes0-1023 var spec rangeHeader.Replace(bytes, ).Split(-); var start long.Parse(spec[0]); var end string.IsNullOrEmpty(spec[1]) ? info.Length - 1 : long.Parse(spec[1]); if (start info.Length || end info.Length || start end) { // 区间非法必须返回 416 并告知总长度否则下载器会无限重试 Response.Headers.ContentRange $bytes */{info.Length}; return StatusCode(StatusCodes.Status416RangeNotSatisfiable); } var stream await _storage.OpenReadAsync(fileId, ct); stream.Seek(start, SeekOrigin.Begin); // 这里强依赖可 Seek Response.StatusCode StatusCodes.Status206PartialContent; Response.Headers.ContentRange $bytes {start}-{end}/{info.Length}; Response.Headers.AcceptRanges bytes; Response.ContentLength end - start 1; return File(stream, info.ContentType); }几个边界值必须处理end留空表示「到文件末尾」得用Length - 1补start超过总长度返回 416 而不是 400Content-Range的格式里/前面是实际区间、后面是总长少写总长部分下载器就无法计算剩余进度。手写这套的代价是要自己管流的位置和响应头收益是能在任意流上工作——只要你能按偏移量取到数据。5. 避坑与排查五个把服务打挂的真实形态5.1 上传报 413 或请求直接断开现象本地测 50MB 正常部署到服务器后前端传大文件要么收到 413要么连接直接断后端日志里一条记录都没有。原因体积限制有三层只改了一层。Kestrel 的MaxRequestBodySize、MVC 的MultipartBodyLengthLimit、反向代理的请求体上限三者取最小。日志里没记录说明请求根本没进到应用层卡在代理或 Kestrel 那一关。解决按链路逐层放开代理层配置请求体上限并适当调大超时应用层两个Configure都写Controller 上对特大文件单独加[RequestSizeLimit]或[DisableRequestSizeLimit]。排查时先看curl -v返回的状态码413 是限制问题连接重置就是代理层的问题。5.2 存进去的文件比原文件小了几个字节现象上传图片能打开但显示不全PDF 报「文件已损坏」对比发现落盘大小比源文件少 8 字节左右。原因校验器里调了一次file.OpenReadStream()读魔数读完没重置位置Controller 里再调OpenReadStream()拿到的流位置可能已经不在 0或者两次读共用同一个底层流导致偏移。解决每次读之前都重新调OpenReadStream()取一个新的流视图不要缓存。或者干脆先读魔数、Seek(0, Begin)复位后再用同一个流。我一般在校验器里直接把流读成byte[]头 8 字节后立刻 dispose绝不留着引用。5.3 中文文件名下载后变成乱码或被截断现象报告-2024.pdf下载下来变成____.pdf或者pdf用某些下载工具还报「文件名非法」。原因只写了filename没写filename*或者filename*里的编码不是 UTF-8 而用了 GBK或者用代替了%20。解决按 4.2 的写法两个字段都写filename用纯 ASCII 兜底filename*用UTF-8前缀加Uri.EscapeDataString。验证方法是curl -I看响应头肉眼确认filename*里的百分号编码对不对。5.4 断点续传失效下载器一直从头开始现象下载大文件时暂停再继续进度条回零重新下或者某些播放器拖动进度条后重新加载整个视频。原因响应里没有Accept-Ranges: bytes或者虽然有 206 但Content-Range格式不对或者流不可Seek导致框架降级成全量返回 200。解决先确认enableRangeProcessing: true有没有开再curl -H Range: bytes0-99 -I看返回码是不是 206、有没有Content-Range。如果返回 200基本就是流不能Seek——本地FileStream可以换成对象存储的包装流就不行了得自己实现按区间取数据。5.5 磁盘每天都在涨却查不到大文件现象分片上传上线两周后磁盘告警du一看chunks目录占了绝大头。原因合并逻辑写完了但删分片的Directory.Delete漏了或者合并失败提前return跳过了清理残留的分片目录就永远躺在那。解决清理动作放在finally里保证无论合并成功失败都执行再加一条定时任务扫chunks下最后修改时间超过 24 小时的目录直接删。这个兜底很重要因为前端放弃上传的概率远比想象中高。6. 二次开发换存储、加鉴权、把验证跑一遍6.1 换存储实现一个 IFileStorage 就够假设要把本地磁盘换成对象存储改动范围应该只有一个新类加一行注册。关键在于OpenReadAsync返回的流能否Seek——多数对象存储 SDK 返回的流不可定位直接交给File(stream, ..., enableRangeProcessing: true)会抛异常。我的做法是在存储层就把区间读能力暴露出来接口加一个带偏移量的重载public interface IFileStorage { Taskstring SaveAsync(Stream source, string originalName, string contentType, CancellationToken ct default); TaskStream OpenReadAsync(string fileId, CancellationToken ct default); // 新增按区间取一段供不可 Seek 的后端使用 TaskStream OpenReadRangeAsync(string fileId, long offset, long length, CancellationToken ct default); TaskStoredFileInfo? GetInfoAsync(string fileId, CancellationToken ct default); Taskbool DeleteAsync(string fileId, CancellationToken ct default); }本地实现里OpenReadRangeAsync就是Seek后截一段返回对象存储实现里则是调 SDK 的 Range 参数。然后在 Controller 里根据流是否CanSeek走不同分支或者干脆统一走自己的 Range 解析第 4.3 节那套。加接口方法会破坏现有实现如果已经有多个实现类用默认接口实现先兜底避免一次性改太多。6.2 加鉴权与限流别让上传接口变成公开写入点文件服务的上传口如果裸奔等于给人开了一个免费网盘。最小改动是两个给写接口挂鉴权策略、给所有接口挂限流。builder.Services.AddAuthorization(o { // 下载允许匿名按需上传删除必须登录 o.AddPolicy(CanWrite, p p.RequireAuthenticatedUser() .RequireClaim(scope, file:write)); }); builder.Services.AddRateLimiter(o { // 按用户维度限流每分钟最多 20 次写操作防止脚本刷盘 o.AddPolicy(write, ctx RateLimitPartition.GetFixedWindowLimiter( ctx.User.Identity?.Name ?? ctx.Connection.RemoteIpAddress?.ToString() ?? anon, _ new FixedWindowRateLimiterOptions { PermitLimit 20, Window TimeSpan.FromMinutes(1) })); });注册之后在 Controller 上加[Authorize(Policy CanWrite)]和[EnableRateLimiting(write)]。注意中间件顺序UseAuthentication、UseAuthorization必须在UseRateLimiter之前否则限流拿不到用户身份退化成按 IP 限流同一出口 IP 后面的用户会互相影响。6.3 验证方法三条命令确认没白改改完之后别只看浏览器能不能传用命令行验证一遍才踏实# 1. 上传确认返回的 fileId 和字节数对得上 curl -F file./big.bin http://localhost:5000/api/files/upload # 2. 分段下载确认返回 206 且 Content-Range 正确 curl -H Range: bytes0-1023 -D - -o /dev/null http://localhost:5000/api/files/{fileId} # 期望看到: HTTP/1.1 206 / Content-Range: bytes 0-1023/104857600 / Accept-Ranges: bytes # 3. 分片续传模拟中途失败后重传 curl -X POST http://localhost:5000/api/files/chunk/init \ -H Content-Type: application/json \ -d {fileHash:abc123,totalChunks:10} # 第二次调用同一个 init返回的 uploaded 数组应该包含已传完的序号并发这块我一般用wrk或者一个简单的并行 curl重点是看三件事进程内存是否稳定不稳说明某处还在把文件读进内存、临时目录是否被清空、Content-Range在并发下有没有串串了就说明fileId和流的绑定有问题。压力测试不要一上来就打 200MB先用 1MB 的小文件把 QPS 打起来确认线程池不会因为同步 IO 被占满再换成大文件测吞吐。讲讲我自己踩过的那个坑早前在LocalFileStorage里把根路径从IOptions读出来之后缓存成了字段配置热更新一改新路径不生效运维那边改了配置文件重启了代理服务却一直往老盘写查了半天才发现是单例里的旧值。从那以后我每次改注入方式都会顺手在启动日志里把生效的根路径、大小限制、允许的扩展名打一行出来出问题第一眼就能对上。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑