直接从项目本身聊起。这个标题其实揭示了两个关键信息:一是视频文件夹的分片上传,二是权限校验的优化。视频文件体积大,分片上传是必然选择;而权限校验在分片场景下很容易被忽视,或者被错误地实现成每片都查数据库。标题里特意提到“国防领域”,意味着这是涉密或高安全要求的网络环境,对权限控制的严格程度、操作留痕、越权防护的要求都比普通互联网应用高一个量级。
在这个背景下,ASP.NET Core 里的拦截器(中间件或过滤器)就成了解决权限校验性能与安全问题的一把利器。这篇文章就把我在某涉密视频档案管理项目里的完整做法拆开讲,包括设计思路、代码实现、性能优化、踩坑记录,全部是可以直接抄作业的干货。
1. 项目背景与整体设计思路
1.1 核心需求解析:为什么分片上传的权限校验让人头疼
先还原一下业务场景。某单位的视频档案库系统,需要支持前端上传大视频文件,单个文件动辄几个 GB。浏览器和网络都不稳定,整包上传基本不可行,所以必须分片上传。前端把文件切成若干 5MB 或 10MB 的分片,逐个发送到后端,后端全部收齐后再合并成完整文件。
权限校验在这个过程里变成了一个容易被低估的问题。普通上传接口可以直接在接收文件的 Action 里加[Authorize],但分片上传有个天然矛盾:分片动辄几十上百个,如果每个分片请求都去数据库查一次用户权限、查一次文件夹权限,数据库压力会非常夸张;如果不查,只校验第一个分片是否合法,又会被攻击者利用——先以一个合法用户的身份上传第一个分片,然后中途换成另一个更高权限的账号继续上传剩余分片,甚至伪造文件夹标识把视频传进无权访问的目录。这在普通业务里可能只是数据混乱,在涉密环境下就是严重的安全事件。
所以这个项目最核心的诉求可以拆成三句话:
- 分片上传接口必须在每次请求都完成权限校验,不能因为性能压力而放松校验强度;
- 校验的性能开销必须足够低,低到不会拖垮大文件上传的吞吐量;
- 权限校验的逻辑必须统一收敛,不能散落在各个 Controller 里,否则兵家大忌——越权漏洞往往就藏在“某个接口忘了校验”这种地方。
1.2 技术选型:为什么用拦截器而不是单纯在 Action 里判断
ASP.NET Core 里做权限控制有几种常见方式:授权中间件(app.UseAuthorization())、控制器/Action 上的[Authorize]、自定义授权过滤器IAuthorizationFilter,以及基于IActionFilter或IAsyncActionFilter的拦截器。还有人会直接用中间件,在请求管道里提前拦截。
这些方案各有优劣。[Authorize]解决的是“你是否登录”的问题,也就是身份认证,但它不关心“你是否有权限对这个特定的视频文件夹执行上传操作”。授权中间件适合做全局性的策略控制,例如“该用户角色是否允许访问所有上传接口”,但它拿不到请求体里的分片参数——视频文件夹标识是放在请求参数或者请求体里的,中间件阶段的模型绑定还没发生,你想读取很别扭。
最后我选的是自定义IAsyncActionFilter。它属于 MVC 过滤器管道中的一环,在 Action 执行之前触发,而这时候模型绑定已经完成,能够直接读到和上传相关的所有参数,包括文件夹标识、分片序号、分片哈希等。这样我们既能够对每一个分片、每一个文件夹做细粒度的权限校验,又能在过滤器里完成统一的逻辑,不需要在每个 Action 里重复写权限判断代码,也不会像中间件那样因为拿不到业务参数而束手束脚。
提示:如果你对 ASP.NET Core 的请求管道不熟悉,可以简单记住:中间件在路由之前、MVC 之外;
IAsyncActionFilter在路由之后、Model Binding 完成时、Action 方法执行之前。分片上传的权限校验需要读取请求参数,所以用IAsyncActionFilter是最自然的。
2. 分片上传权限校验的拦截器实现
2.1 校验流程设计:每个分片都要查,但不能每次都查数据库
先讲清楚校验流程的骨架。整个拦截器的逻辑分为四步:
- 从请求参数中提取目标文件夹标识和分片序号。不同前端框架传参方式不一样,有的放在 URL Query 里,有的放在请求体里,我统一封装了一个
UploadChunkRequest模型,用模型绑定收参数,拦截器里直接读取绑定后的强类型对象。 - 验证上传凭证。这里不使用传统 Cookie 会话,而是使用自定义的访问令牌(Token)。每个分片请求的 Header 里都携带
X-Access-Token,拦截器先解密并校验 Token 是否有效,读取 Token 中的用户身份摘要。 - 加载并校验文件夹权限。拿到用户身份和文件夹标识后,调用
IFolderPermissionService检查该用户对目标文件夹是否拥有上传权限。这个环节是最容易卡性能的,后面专门讲缓存优化。 - 校验结果写入 HttpContext.Items。后续的 Action 方法、日志中间件、审计模块都可以直接从
HttpContext.Items里取权限校验结果,避免重复校验。
这里有一个设计上的关键决策:为什么要每个分片都校验,而不是只校验第一个分片?答案在于分片上传的请求是可以被任意伪造的。攻击者完全可以绕过前端界面,用 curl 直接向服务器发送“第 20 片”的上传请求,而服务器根本无法判断这个分片是不是真的来自那一次合法上传会话。如果只校验第一个分片,后续分片的请求就是裸奔状态。在涉密场景里,哪怕只是一个分片的越权写入,都有可能造成泄密风险。所以结论很明确:每个分片都校验,一步都不能省。
2.2 核心代码:带缓存校验的 IAsyncActionFilter
直接上代码。下面这个拦截器就是我当时项目中实际使用的版本,去掉了一些本地特有的配置,核心部分保持一致。
public class UploadAuthorizationFilter : IAsyncActionFilter { private readonly ITokenService _tokenService; private readonly IFolderPermissionService _permissionService; private readonly IPermissionCache _permissionCache; private readonly ILogger<UploadAuthorizationFilter> _logger; public UploadAuthorizationFilter( ITokenService tokenService, IFolderPermissionService permissionService, IPermissionCache permissionCache, ILogger<UploadAuthorizationFilter> logger) { _tokenService = tokenService; _permissionService = permissionService; _permissionCache = permissionCache; _logger = logger; } public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var chunkRequest = context.ActionArguments.Values .OfType<UploadChunkRequest>() .FirstOrDefault(); if (chunkRequest == null) { context.Result = new BadRequestObjectResult("缺少分片上传参数"); return; } // 1. 令牌校验 var token = context.HttpContext.Request.Headers["X-Access-Token"].FirstOrDefault(); var userSession = _tokenService.ValidateAndDecrypt(token); if (userSession == null) { context.Result = new UnauthorizedObjectResult("访问令牌无效或已过期"); return; } // 2. 本地缓存优先,避免同用户同文件夹高频查库 var cacheKey = $"{userSession.UserId}:{chunkRequest.FolderId}:upload"; var cachedAllow = _permissionCache.Get(cacheKey); if (cachedAllow == null) { var allow = await _permissionService.CheckFolderUploadPermissionAsync( userSession.UserId, chunkRequest.FolderId); _permissionCache.Set(cacheKey, allow, TimeSpan.FromMinutes(5)); cachedAllow = allow; } if (!cachedAllow.Value) { _logger.LogWarning("用户 {UserId} 尝试向无权访问的文件夹 {FolderId} 上传分片,分片序号 {ChunkIndex}", userSession.UserId, chunkRequest.FolderId, chunkRequest.ChunkIndex); context.Result = new ForbidResult("无权访问该文件夹"); return; } // 3. 将校验结果存入 Items,Action 内部可直接读取 context.HttpContext.Items["UserSession"] = userSession; context.HttpContext.Items["FolderId"] = chunkRequest.FolderId; context.HttpContext.Items["ChunkIndex"] = chunkRequest.ChunkIndex; await next(); } }几个细节值得单独说明。
UploadChunkRequest是一个强类型请求模型,里面包含FolderId、ChunkIndex、TotalChunks、FileHash、ChunkHash等字段。拦截器之所以能从ActionArguments里把它捞出来,是因为 ASP.NET Core 在进入过滤器之前已经完成了模型绑定。这意味着前端传参时必须保证字段名和后端模型属性名能正确匹配(大小写不敏感),否则拦截器拿不到参数,直接就返回 400 了。
IPermissionCache是权限结果缓存服务,本质是一个线程安全的 MemoryCache 封装。它的存在很关键:同一个用户向同一个文件夹连续上传几十个分片,如果每个分片都查一次数据库里的权限表,数据库事务量会暴涨,特别是在同时有多个用户并行上传大视频的场景下,很容易把数据库连接池打满。加入 5 分钟的有效缓存后,第一个分片查一次库,后续分片直接命中缓存,性能提升非常明显。
ForbidResult是 ASP.NET Core 内置的禁止访问结果,会触发认证中间件的默认处理流程友好返回 403 状态。如果你希望和前端的全局错误处理统一风格,也可以自定义一个JsonResult返回统一的错误结构体。另外务必注意日志必须记录分片序号、用户标识、文件夹标识,因为一旦发生越权尝试,事后审计全靠这些日志回溯。
2.3 拦截器注册与排除规则:别把所有上传接口都拦进来
有了拦截器,还需要把它挂到请求管道上。我这里用的是 MVC 过滤器的方式,在Program.cs里注册:
builder.Services.AddControllers(options => { options.Filters.Add<UploadAuthorizationFilter>(); });但这样会把所有 Controller Action 都拦截住,包括登录接口本身,那显然不行。所以还需要配合[AllowAnonymous]或者按接口类型进行排除。更精确的做法是在注册时用传入的TypeFilterAttribute绑定特定控制器,或者在全局过滤器里用一个开关属性来控制:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] public class SkipUploadAuthorizationAttribute : Attribute { }然后在过滤器第一步先检查:
var hasSkip = context.ActionDescriptor.EndpointMetadata .Any(meta => meta is SkipUploadAuthorizationAttribute); if (hasSkip) { await next(); return; }我自己更倾向于后者,用白名单的思维:默认全部拦截,明确标注允许放行的接口才放行。这样以后新增接口时天然就是带着权限校验的,不会因为忘记贴特性而出漏洞。这种“默认拒绝”的思路在高安全级别项目里非常重要。
提示:
TypeFilterAttribute和普通的ServiceFilterAttribute有细微区别。TypeFilterAttribute允许你直接使用没有注册到容器中的类型,并且可以通过参数传参,适合在单一场景下使用;如果你希望过滤器实例以单例方式复用,就用ServiceFilterAttribute并在服务容器里注册。本项目里拦截器依赖的都是瞬态服务,所以我用TypeFilterAttribute注册。
3. 分片上传的权限校验痛点与优化方案
3.1 性能瓶颈:一条 SQL 引发的连锁问题
最初实现权限校验时,我的第一版代码是在UploadChunk这个 Action 里直接调_permissionService.CheckFolderUploadPermissionAsync,而且当时图省事,没有加缓存。压测环境里模拟 10 个用户同时各上传一个 1GB 视频,每个视频切 200 片,总共 2000 个分片请求,压测数据一出来就把我吓了一跳——数据库 CPU 直接飙到接近满负荷,平均响应时间从几十毫秒涨到了快 2 秒,上传成功率骤降。
原因很简单:2000 个请求里,大部分请求都在执行同一条权限查询 SQL,而且这些 SQL 的输入参数高度重复——同一个用户对同一个文件夹往往连续发起几十个分片请求。数据库白花花的算力全部耗在了毫无区分度的重复劳动上。这就是典型的 N+1 查询问题,只不过这里的 N+1 不是查询主表加从表,而是“权限查库一次 + 分片每次查库一次”的组合拳。
3.2 缓存策略:5 分钟规则和它的边界
我的优化方案就是前面讲的IPermissionCache,它把“用户、文件夹、操作类型”三元组作为键,把权限结果作为值,缓存 5 分钟。这个时长不是拍脑袋定的,需要权衡两个因素:
- 一是安全时效性。如果用户上传过程中被管理员撤销了文件夹权限,理论上应该尽快生效,缓存时间太长会让这个用户继续上传很长时间。5 分钟是一个折中值,既能挡住绝大多数手动调整权限操作的延迟风险,又能保证缓存命中率。
- 二是分片上传的总时长。一个典型视频分片上传过程通常持续数分钟到十几分钟,5 分钟缓存已经可以覆盖“同一个文件夹连续上传几十片”的高频场景。如果缓存时间太短,比如只有 1 分钟,那么当用户上传速度较慢、每个分片间隔超过 1 分钟时,缓存会反复失效,又退回查库的老路。而缓存时间太长,比如 30 分钟,权限撤销的延迟就无法接受。
这里特别提醒一个缓存陷阱:不要只缓存“允许上传”,还要缓存“拒绝上传”。攻击者试探性地向无权文件夹发送分片请求后,如果在你的缓存里留下的是“无记录”,那下次同样试探还会继续穿透到数据库,这样你就相当于给攻击者提供了一个免费的高频探测通道。正确做法是无论允许还是拒绝,都缓存结果。这样可以显著降低无效请求对数据库的压力,也能在一定程度上隐藏权限判断的内部逻辑。
3.3 防越权与防篡改:分片文件的内容完整性校验
权限校验只是第一道防线。在涉密视频场景里,还有一类更隐蔽的攻击方式叫“分片替换”。攻击者可能有权限向 A 文件夹上传视频,但没有权限向 B 文件夹上传。他可以先合法地向 A 上传第一个分片,拦截网络请求,篡改请求里的 FolderId 参数,把后面的分片指向 B 文件夹。由于 A 和 B 可能在同一个物理存储卷,如果后端只校验用户对目标文件夹的权限而忽略了文件本身的内容校验,这种攻击就很难被发现。
所以我在拦截器校验通过后,还会在 Action 里对分片做双重校验:一是分片的哈希校验,前端在上传时同时提交ChunkHash,后端计算接收到分片的实际哈希值,两者对比,不一致直接丢弃并返回错误;二是文件聚合后的整体哈希校验,在所有分片合并完成后,用文件的整体哈希值与前端提交的FileHash对比,确认整个文件在传输过程中没有被篡改。
public async Task<IActionResult> UploadChunk([FromBody] UploadChunkRequest request) { var userSession = (UserSession)HttpContext.Items["UserSession"]; var folderId = (string)HttpContext.Items["FolderId"]; // 分片原始内容 var chunkBytes = Convert.FromBase64String(request.ChunkBase64); var actualHash = SHA256.HashData(chunkBytes); var actualHashString = Convert.ToHexString(actualHash); if (!string.Equals(actualHashString, request.ChunkHash, StringComparison.OrdinalIgnoreCase)) { _logger.LogWarning("分片哈希不一致:用户 {UserId},文件夹 {FolderId},分片序号 {ChunkIndex}", userSession.UserId, folderId, request.ChunkIndex); return BadRequest(new { code = 40014, message = "分片内容校验失败,请重传" }); } // 写入临时分片文件 await _chunkStorage.SaveChunkAsync(folderId, request.FileHash, request.ChunkIndex, chunkBytes); // 如果所有分片都已上传,则触发合并 if (await _chunkStorage.IsAllChunksUploadedAsync(folderId, request.FileHash, request.TotalChunks)) { await _chunkStorage.MergeChunksAndValidateAsync(folderId, request.FileHash); } return Ok(new { code = 0, message = "分片上传成功" }); }这里有个设计经验:分片临时文件不要直接按“文件夹 ID”分目录存储,而是按“随机批次号”分目录存储,例如uploads/{batchId}/{chunkIndex}.part。batchId是前端生成的 GUID,和用户权限没有直接关联。这样即使后端的目录结构被意外泄露(比如通过某个异常日志输出路径),攻击者也无法从路径推断出项目内部的组织结构。
4. 拦截器之外:全局配置与监控
4.1 与鉴权中间件的分工配合
很多人容易把拦截器和鉴权中间件的职责搞混。我在这项目里的经验是,两者要协同但不能越俎代庖。
- **鉴权中间件(
UseAuthentication+UseAuthorization)**负责确认“你是谁”,验证 JWT Token 或自定义令牌的签名和有效期,并把解析出的用户标识塞进ClaimsPrincipal。这是一切后续操作的基础。 - **自定义
IAsyncActionFilter**负责确认“你能不能干这件事”,在拿到用户身份之后,再去校验该用户对目标文件夹的权限,并决定是否放行。
换句话说,鉴权中间件在前面把守大门,拦截器在进入具体业务代码前把关。如果只用中间件,你只能做到“登录用户都可以上传”,做不到“指定文件夹只有授权用户才能上传”;如果只用拦截器,你需要在拦截器里重复实现 Token 解析、签名校验等底层逻辑,等于把安全基建重造了一遍轮子。把两者拆开,各司其职,维护起来最清晰。
我在项目中另外还加了一个UseMiddleware<RequestAuditMiddleware>(),用于在每次上传请求完成之后记录完整的审计日志,包括用户标识、文件夹标识、分片序号、文件大小、上传耗时、来源 IP。这些日志会写入独立的审计日志文件中,定期归档。涉密项目的审计要求是能追踪到“每一个文件、每一个分片、每一次操作”的,所以这一步不能省。需要注意的是,RequestAuditMiddleware必须挂在UseAuthorization之后,这样才能保证在记录日志时,HttpContext.Items里已经有拦截器写入的用户会话信息。
4.2 密钥管理与敏感配置
拦截器里的 Token 解密和权限服务都会涉及密钥。涉密项目的密钥管理不能和普通互联网应用一样把密钥放在appsettings.json里就算完。我这边用的是 Azure Key Vault 的方案,但内网环境无法完全照搬,最后采用的是自建的密钥管理服务,把主密钥存放在独立的硬件密码机中,应用启动时从密码机获取密钥并加载到内存,不在任何配置文件中落盘。
这里有一个容易踩的坑:ASP.NET Core 的IConfiguration是支持多级配置源的,如果密钥配置同时出现在环境变量和 appsettings 中,后添加的配置源会覆盖先添加的。密钥一旦被覆盖成错误的值,拦截器里的 Token 解密就会全面失效,所有上传请求都会被强行拒绝。这个故障的排查难度极高,因为应用不会在启动时报错,只会在运行时报大量 401。所以我对密钥配置做了启动自检,在Program.cs里加一步密钥可用性校验,校验失败直接退出进程,避免服务带病上线。
var encryptionKey = builder.Configuration.GetValue<string>("Security:EncryptionKey"); if (string.IsNullOrEmpty(encryptionKey) || encryptionKey.Length < 32) { throw new InvalidOperationException("加密密钥未正确配置,应用启动终止"); }4.3 并发上传的资源控制
分片上传本身是一个吃带宽和磁盘的操作。在权限校验通过后,如果应用不做任何并发控制,当大量用户同时上传大视频时,磁盘 I/O 会瞬间被打满,形成资源争抢,甚至拖垮其他无关业务接口。
我在ChunkStorageService里引入了一个简单的信号量控制,限制同时进行写盘操作的任务数量:
private static readonly SemaphoreSlim WriteGate = new(4); public async Task SaveChunkAsync(string folderId, string fileHash, int chunkIndex, byte[] chunkBytes) { await WriteGate.WaitAsync(); try { // 模拟或实际的写盘操作 await File.WriteAllBytesAsync(GetChunkPath(folderId, fileHash, chunkIndex), chunkBytes); } finally { WriteGate.Release(); } }这个信号量把同时写盘的任务限制在 4 个以内,超出的请求会排队等待。注意这里用的是SemaphoreSlim而不是lock,因为lock不允许异步等待,用SemaphoreSlim配合WaitAsync才能和异步文件操作有效配合。并发数 4 是在普通机械硬盘上的经验值,如果项目部署在 SSD 环境可以适当调大,但别调到 16 以上,否则线程调度和日志 IO 本身也会成为瓶颈。
4.4 前端配合:拦截器校验的参数约定
拦截器要正常工作,前端参数格式必须匹配。这里把前后端约定的参数结构列出来,方便大家直接参考:
| 参数名 | 类型 | 说明 |
|---|---|---|
| FolderId | string | 目标文件夹全局唯一标识 |
| ChunkIndex | int | 当前分片序号,从 0 开始 |
| TotalChunks | int | 总分片数 |
| FileHash | string | 整个文件的 SHA-256 哈希值 |
| ChunkHash | string | 当前分片的 SHA-256 哈希值 |
| ChunkBase64 | string | 分片内容的 Base64 编码 |
前端在上传前需要先查询文件夹权限,拿到上传凭证(Token),再把凭证放到每个分片请求的X-Access-TokenHeader 里。拦截器校验 Token 时不需要把 Token 解析出的用户 ID 与请求体里的某个用户字段对比,因为 Token 本身就是唯一可信的用户身份来源,请求体里不应该再出现用户 ID,否则就有伪造的空间。
关于 Base64 编码,我要特别提醒一句:把分片内容编码成 Base64 会增大约 33% 的传输体积。通常更推荐用multipart/form-data方式直接传二进制流。我之前为了联调方便用了 Base64,线上环境果断改成了IFormFile方式,体积膨胀明显减少。如果你的分片是 10MB,Base64 后就是 13.3MB,一百个分片就多传了 300 多 MB 流量,这个开销在涉密内网里虽然不至于致命,但也完全没必要浪费。
5. 分片上传权限校验方案对比与总结
5.1 三种权限校验实现方式对比
很多人问,为什么不用更简单的方案?我在做技术决策时做了一个小对比,列在这里供参考:
| 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 在 Action 内手写校验 | 实现简单,逻辑直观 | 校验代码散落在各 Action,容易遗漏;重复代码多;后期维护成本高 | 只有一两个上传接口,不涉及复杂权限模型 |
| 自定义授权中间件 | 统一入口,全局生效 | 拿不到模型绑定后的参数,无法读取 FolderId / ChunkIndex;在涉密场景中校验不细 | 全局粗粒度访问控制,例如 IP 白名单、基本登录校验 |
自定义IAsyncActionFilter | 参数齐全、校验粒度细、可复用可组合,符合 AOP 思想 | 依赖模型绑定参数,参数缺失时需额外处理 | 分片上传、依赖业务参数的细粒度权限校验 |
这里我必须强调一下为什么中间件不适合做细粒度权限。ASP.NET Core 中间件在管道中的位置很靠前,在 MVC 的模型绑定发生之前就已经执行了。如果你在中间件里想读取FolderId,标准的ReadFormAsync会把请求体消费掉,等后面 MVC 真正要模型绑定时就会发现流已经读完了,直接报错。有很多人在这里踩坑,费了半天劲去重写请求流,最终结果还不如用过滤器干净。所以,如果你要读取请求体参数来判别权限,就应该用IAsyncActionFilter,不要用中间件做这件事。
5.2 防越权的一个核心原则:永不信任前端传值
无论拦截器怎么做,项目里最关键的一条安全原则是“永不信任前端传值”。这句口号很多人听过,但在分片上传这个场景里,它是容易犯错的。因为分片上传的请求里充满了大量由前端决定的参数:FolderId、ChunkIndex、FileHash、TotalChunks,这些全部可以被恶意用户修改。
- FolderId 可以被篡改成其他文件夹 —— 拦截器校验目标文件夹权限,防止越权上传;
- ChunkIndex 可以被篡改,导致分片顺序混乱 —— 合并时严格检查分片序号连续性和去重性;
- FileHash 可以被替换成攻击者自己的文件哈希 —— 合并后必须实际计算哈希,和前端承诺比对。
我见过一个反面案例:某系统在做视频上传时,只校验了登录状态和文件夹存在性,完全依赖前端传入的FileHash作为合并后的文件名。攻击者构造一个 Web 请求,把FileHash设置成目标服务器上已有的敏感文件路径,再配合路径穿越符号,直接就把上传的分片内容写到了目标位置。这个漏洞的本质就是“信任了前端传值 + 没有做路径校验”。所以,分片上传的权限校验,绝不只是“你能否上传到该文件夹”,还包括“你上传的内容是否真的会被正确地放置到预期路径”。拦截器解决的是前者,Action 里的路径校验和哈希校验解决的是后者,缺一不可。
5.3 上线前必须做的几个安全测试
项目上线之前,我总结了几个必须做的安全测试点,如果你要复用这套分片上传权限校验方案,建议跟着过一遍:
- 使用两个不同权限的账号,模拟分片中途切换账号。应确保第二个账号上传的分片被拦截,或者合并失败。
- 构造一个不存在的 FolderId 请求,观察是否出现异常日志泄露目录结构或内部参数。
- 修改请求中的 FolderId,尝试把合法分片上传到无权访问的文件夹,预期 403。
- 修改分片的 ChunkHash 为错误值,预期分片被拒绝写入。
- 模拟大量并发分片上传时,数据库 QPS 是否处于可控范围,缓存是否生效。
- 尝试重复上传同一个分片序号,预期合并环节去重正确,不会覆盖已有的有效分片。
这六项测试我在项目里全部都做过。第一项和第二项都发现过实际的问题:第一版代码里,分片中途切换账号的请求居然可以成功写入临时目录,因为临时文件存储目录用的是全局共享目录而不是按会话隔离;第二项测试里,异常日志把文件夹不存在的信息直接抛到了响应中,被我们拦下来改成通用错误提示。这些都是测试才能发现的问题,千万别指望代码 review 阶段能全看出来。
6. 常见问题与排查技巧实录
6.1 拦截器没有生效,请求直接进入了 Action
这个现象通常有四类原因:
- 过滤器没有注册成功。请检查
Options.Filters.Add<UploadAuthorizationFilter>()是否真的执行了,注册代码是否放在了AddControllers的options这个 lambda 里,而不是放在服务容器中。 - 接口贴了
[AllowAnonymous]。AllowAnonymous过滤器会跳过 MVC 授权过滤器,但不会跳过自定义的IAsyncActionFilter。如果你看到请求直接进入了 Action 且没有走拦截逻辑,大概率是你的拦截器里手动判断了端点元数据中的AllowAnonymous,或者你使用的全局过滤器实现方式有问题。 - Action 的参数模型和拦截器里读取的模型类型不匹配。拦截器用
OfType<UploadChunkRequest>()找参数,如果 Action 用的是自定义的其他类型,此处返回 null,请求直接被拦截返回 400。这种情况不是“没生效”,而是“被默认拒绝”,但调试时很容易误判。 - 多过滤器之间的执行顺序问题。
IAsyncActionFilter的Order属性决定执行顺序,如果你注册了多个过滤器,且权限过滤器的Order值大于其他过滤器的Order值,那么它会在其他过滤器之后执行。如果那个过滤器抛了异常,你的权限过滤器可能压根不会运行。排查顺序:先看启动日志确认过滤器是否注册,再打断点看ActionExecutingContext里的ActionArguments是否包含了预期参数。
6.2 请求体在拦截器里读取后,Action 拿不到参数
这是最经典的坑。如果你在IAsyncActionFilter里用request.ReadFromJsonAsync或者其他方式主动读取了请求体,即使后面的模型绑定已经完成,已经需要重新读取时发现流已消费。过滤器的执行时机虽然晚于模型绑定,但它仍然可以访问原始 Request.Body,如果你在过滤器里做了一次读取,就有可能导致后续的日志中间件或重试框架无法再次读取。
我的经验是:过滤器里永远不要手动读取 Request.Body。需要的数据通过context.ActionArguments获取,也就是让模型绑定完成后直接从强类型参数里拿。如果你的需求是读取了参数之后还要读取原始 Body 做签名校验,建议你先调用Request.EnableBuffering(),这样 Body 流可以被多次读取。
6.3 ForbidResult 返回的响应格式不对,前端无法解析
默认的ForbidResult会触发认证中间件的默认响应处理,通常会被重定向到登录页或者返回一个空的 403。对于前后端分离的接口,这不是友好的行为。更好的做法是直接返回ObjectResult,这样响应格式由全局异常处理中间件统一控制:
context.Result = new ObjectResult(new { code = 403, message = "无权访问该文件夹" }) { StatusCode = StatusCodes.Status403Forbidden };这里再补充一个细节:如果使用ForbidResult,它要求的是一次已经通过认证的请求。也就是说,如果用户在未登录状态下访问上传接口,ForbidResult会触发认证中间件把它重定向到登录页,而前端 SPA 可能根本不知道如何处理这种 302 重定向,导致非常难排查的“莫名其妙的响应”。而如果你用UnauthorizedObjectResult就只返回 401,语义更清晰,也更容易和前端统一处理方案。建议权限不足时返回 403,未登录时返回 401,前端根据状态码决定是否跳转登录页。
6.4 权限缓存的失效风暴
当用户数量较多,且每个用户的权限频繁变化时,有可能出现缓存大面积失效的场景。比如管理员一次批量撤销了某个部门所有用户的权限,涉及 100 个用户、50 个文件夹,会产生 5000 个缓存键失效。如果这些用户同时在上传视频,就会瞬间打满数据库连接。
排查这类问题,要在监控面板上同时观察两项指标:权限校验缓存命中率和数据库 CPU。如果命中率低于 80%,或者数据库 CPU 在上传高峰时明显飙升,就要检查权限服务接口的调用量是否有突刺。解决思路有三种:
- 增大缓存 TTL,比如从 5 分钟调到 10 分钟;
- 在权限变更时主动失效相关缓存,而不是依赖过期时间被动清理;
- 在权限服务前再加一层进程内缓存,用内存数据库保存热点用户的权限摘要。
我最后是在这两者之间取了平衡:普通权限变更仍走 5 分钟过期,但“禁用用户”这种高优先级操作会主动调用缓存刷新接口,把该用户的全部权限缓存立即清空。这样才能兼顾性能和敏感操作及时生效的诉求。
6.5 分片合并时的重复分片与缺失分片
分片上传过程中,由于网络原因,前端可能会重传同一个分片。后端如果不做去重,合并时会发现分片数量对得上,但部分分片内容重复、部分缺失,导致文件损坏。这个问题在权限校验过滤器和缓存之外,但也和整个分片上传流程紧密相关,值得提一下。
我的做法是在写入分片时记录一个“上传状态表”,以FolderId + FileHash + ChunkIndex为唯一键。每次写入前检查该分片是否已存在,已存在且哈希一致就直接返回成功,不重复写盘;已存在但哈希不一致,说明数据异常,丢弃新分片并记录警告日志。这个状态表同时承担“合并校验”的职责,当目标是“所有分片全部上传成功且无重复”时,才触发合并流程。
这个去重逻辑没有放在过滤器里,而是放在了ChunkStorageService,因为写文件本身涉及 IO,不适合在过滤器的 AOP 里处理。但要注意去重检查必须和分片写入在同一个事务或同一个锁粒度内,否则多个并发请求同时写入同一个分片序号,可能产生竞态条件。
7. 实践经验与后续优化建议
7.1 从拦截器到“微授权中心”的演进
这个项目的权限校验最开始只是为了解决分片上传的问题,但做着做着就发现一个规律:所有业务模块其实都有类似的“细粒度权限校验”需求——用户能不能下载这个视频,用户能不能删除这条记录,用户能不能编辑那份文档。逐个子系统去写过滤器,重复代码满天飞。
所以后来我把权限校验逻辑抽到了统一的“授权中心”服务里,用枚举区分操作类型,比如UploadOperation、DownloadOperation、DeleteOperation,缓存键也从UserId:FolderId:upload变成了UserId:ResourceId:OperationType,进一步抽象成了资源 ID 加操作类型的通用结构。分片上传的拦截器只是这个授权中心的一个消费方。这样后续其他模块接入权限控制时,只需要在 Action 上标注资源类型和操作类型,授权中心自动完成校验,不需要再写第二套过滤器。如果你在实践这个方案,我强烈建议一开始就按这种“通用授权中心”的思维设计,不要只盯着分片上传这一亩三分地。
7.2 拦截器内日志级别的控制
最后分享一个看起来很小但实际改善明显的经验:拦截器里的日志级别控制。权限校验失败时的LogWarning是必要的、有审计价值的;但校验成功时的日志,如果也写成LogInformation或者LogDebug,在高并发上传场景下会产生大量无意义的日志。我当时在压测时发现,日志写入本身占用了不少 CPU 时间,尤其是每次上传分片都写一条“用户 X 向文件夹 Y 上传成功”的日志,压测持续 10 分钟就能产生上百万条日志。
处理方法是在拦截器中区分日志级别:校验失败时用Warning,校验成功时用Debug,并且把日志开关做成配置项。生产环境默认关闭成功日志,只在疑难问题时临时打开 Debug 日志定位问题。这样既保证了安全事件可追溯,又不给正常运行带来性能负担。
7.3 给新项目团队的三点建议
如果你要在团队里推行这套方案,我最后有三点建议:
- 先把安全设计文档写清楚再动手写代码。明确“每个分片请求都必须校验”这一原则,并让前后端同学都清楚分片上传的参数约定和权限模型,避免开发到一半因为传参方式不一致返工。
- 权限校验逻辑要独立成服务,别写进 Controller。拦截器只是入口,真正的权限判断逻辑放在
IFolderPermissionService中,方便单元测试和后续扩展。这个服务可以单独跑测试,让不熟悉拦截器的同事也能快速理解和维护。 - 不要迷信网上分片上传的现成代码。很多开源项目直接把分片合并逻辑写在 Controller 里,权限校验贴着最小可用原则实现,这在普通场景没问题,但在低容错场景下远远不够。要结合自己对合规性、审计、防篡改的要求,做二次加固。
我自己在走完这个项目之后最明显的感觉是:权限校验这件事,实现难度不在于“写一个判断逻辑”,而在于“在所有应该校验的地方都严格校验,且不拖垮性能”。拦截器给了我们一个集中收敛的入口,缓存给了我们性能保障,而剩下的就要靠团队对安全底线的坚持——每一个分片、每一次合并、每一份审计日志,都值得认真对待。