ASP.NET Core 文件上传下载深入
大约 18 分钟约 5339 字
ASP.NET Core 文件上传下载深入
简介
文件上传下载是 Web 应用最常见的基础功能之一。ASP.NET Core 提供了 IFormFile 模型绑定、流式上传、SignalR 实时进度等多种方式来处理文件传输。本文将系统讲解从小文件上传到超大文件分片断点续传、从本地存储到 OSS/MinIO 分布式存储、从安全校验到 CDN 加速的完整技术方案。
文件上传看似简单,但实际生产环境中需要考虑的问题非常多:超大文件如何不撑爆内存?恶意文件如何识别和拦截?断网后如何续传?分布式环境下文件如何同步?这些问题的答案构成了一个完整的文件处理架构。
特点
基础上传
IFormFile 基本上传
/// <summary>
/// IFormFile 基本文件上传 — 适合小文件(< 100MB)
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class FileController : ControllerBase
{
private readonly IFileStorageService _storage;
private readonly ILogger<FileController> _logger;
public FileController(IFileStorageService storage, ILogger<FileController> logger)
{
_storage = storage;
_logger = logger;
}
/// <summary>
/// 单文件上传
/// </summary>
[HttpPost("upload")]
[RequestSizeLimit(100 * 1024 * 1024)] // 100MB 限制
public async Task<IActionResult> Upload(IFormFile file)
{
// 1. 基本验证
if (file == null || file.Length == 0)
return BadRequest("请选择要上传的文件");
// 2. 扩展名白名单校验
var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".pdf", ".docx" };
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!allowedExtensions.Contains(extension))
return BadRequest($"不支持的文件类型:{extension}");
// 3. 文件大小校验
if (file.Length > 50 * 1024 * 1024)
return BadRequest("文件大小不能超过 50MB");
// 4. MIME 类型校验
var allowedMimeTypes = new[] { "image/jpeg", "image/png", "image/gif",
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" };
if (!allowedMimeTypes.Contains(file.ContentType))
return BadRequest($"不支持的 MIME 类型:{file.ContentType}");
// 5. 生成安全的文件名
var safeFileName = $"{Guid.NewGuid()}{extension}";
var filePath = $"uploads/{DateTime.UtcNow:yyyy/MM/dd}/{safeFileName}";
// 6. 存储文件
using var stream = file.OpenReadStream();
var url = await _storage.SaveAsync(filePath, stream, file.ContentType);
_logger.LogInformation("文件上传成功:{FileName} -> {Url}, 大小:{Size} bytes",
file.FileName, url, file.Length);
return Ok(new
{
OriginalName = file.FileName,
Url = url,
Size = file.Length,
ContentType = file.ContentType,
UploadTime = DateTime.UtcNow
});
}
/// <summary>
/// 多文件上传
/// </summary>
[HttpPost("upload-batch")]
public async Task<IActionResult> UploadBatch(List<IFormFile> files)
{
if (files == null || files.Count == 0)
return BadRequest("请选择要上传的文件");
// 限制批量上传数量
if (files.Count > 20)
return BadRequest("单次最多上传 20 个文件");
var results = new List<FileUploadResult>();
var errors = new List<string>();
foreach (var file in files)
{
try
{
if (file.Length == 0) continue;
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
var safeFileName = $"{Guid.NewGuid()}{extension}";
var filePath = $"uploads/{DateTime.UtcNow:yyyy/MM/dd}/{safeFileName}";
using var stream = file.OpenReadStream();
var url = await _storage.SaveAsync(filePath, stream, file.ContentType);
results.Add(new FileUploadResult
{
OriginalName = file.FileName,
Url = url,
Size = file.Length
});
}
catch (Exception ex)
{
errors.Add($"{file.FileName}: {ex.Message}");
}
}
return Ok(new { Success = results, Errors = errors, Total = results.Count });
}
}
public record FileUploadResult
{
public string OriginalName { get; init; }
public string Url { get; init; }
public long Size { get; init; }
}大小限制配置
全局与端点级别限制
/// <summary>
/// 文件上传大小限制配置 — 三种级别
/// </summary>
// ========== 方式1:全局配置(Program.cs)==========
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 100 * 1024 * 1024; // 100MB
options.ValueLengthLimit = int.MaxValue;
options.MultipartHeadersLengthLimit = int.MaxValue;
options.BufferBody = false; // 大文件不缓冲到内存
});
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 2L * 1024 * 1024 * 1024; // 2GB
options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(30);
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(30);
});
// IIS 托管时
builder.Services.Configure<IISServerOptions>(options =>
{
options.MaxRequestBodySize = 2L * 1024 * 1024 * 1024;
});
// ========== 方式2:端点级别配置 ==========
[HttpPost("upload-large")]
[RequestSizeLimit(200 * 1024 * 1024)] // 200MB
[RequestFormLimits(MultipartBodyLengthLimit = 200 * 1024 * 1024)]
public async Task<IActionResult> UploadLarge(IFormFile file)
{
// 该端点允许 200MB
return Ok();
}
// ========== 方式3:禁用限制(配合流式上传使用)==========
[HttpPost("upload-stream")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 2L * 1024 * 1024 * 1024)]
public async Task<IActionResult> UploadStream()
{
// 手动控制大小
return Ok();
}流式上传(大文件)
流式上传避免内存溢出
/// <summary>
/// 流式上传 — 不缓冲到内存,适合大文件
/// </summary>
[HttpPost("upload-stream")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 2L * 1024 * 1024 * 1024)]
public async Task<IActionResult> UploadStream()
{
if (!Request.HasFormContentType)
return BadRequest("请求必须是 multipart/form-data 格式");
var boundary = HeaderUtilities.RemoveQuotes(
MediaTypeHeaderValue.Parse(Request.ContentType).Boundary).Value;
var reader = new MultipartReader(boundary, Request.Body);
var section = await reader.ReadNextSectionAsync();
while (section != null)
{
var contentDisposition = ContentDispositionHeaderValue.Parse(section.ContentDisposition);
var fileName = contentDisposition.FileName.Value?.Trim('"');
if (!string.IsNullOrEmpty(fileName))
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
var safeFileName = $"{Guid.NewGuid()}{extension}";
var filePath = Path.Combine("uploads", "large",
DateTime.UtcNow.ToString("yyyy/MM/dd"), safeFileName);
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
// 直接流式写入磁盘,不占用内存
long totalBytes = 0;
using (var fileStream = new FileStream(filePath, FileMode.Create))
{
var buffer = new byte[81920]; // 80KB 缓冲区
int bytesRead;
while ((bytesRead = await section.Body.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
await fileStream.WriteAsync(buffer, 0, bytesRead);
totalBytes += bytesRead;
}
}
_logger.LogInformation("大文件流式上传完成:{FileName},大小:{Size:N0} bytes",
fileName, totalBytes);
}
section = await reader.ReadNextSectionAsync();
}
return Ok(new { Message = "上传成功" });
}分片上传与断点续传
分片上传服务
/// <summary>
/// 大文件分片上传服务 — 支持断点续传
/// 前端将大文件分割为多个 chunk,逐个上传
/// </summary>
public class ChunkedUploadService
{
private readonly IFileStorageService _storage;
private readonly ILogger<ChunkedUploadService> _logger;
private readonly ConcurrentDictionary<string, UploadSession> _sessions = new();
/// <summary>
/// 创建上传会话
/// </summary>
public UploadSession CreateSession(string fileName, long fileSize, int chunkSize)
{
var sessionId = Guid.NewGuid().ToString("N");
var totalChunks = (int)Math.Ceiling((double)fileSize / chunkSize);
var session = new UploadSession
{
SessionId = sessionId,
FileName = fileName,
FileSize = fileSize,
ChunkSize = chunkSize,
TotalChunks = totalChunks,
UploadedChunks = new HashSet<int>(),
CreatedAt = DateTime.UtcNow,
TempDirectory = Path.Combine(Path.GetTempPath(), "uploads", sessionId)
};
Directory.CreateDirectory(session.TempDirectory);
_sessions[sessionId] = session;
_logger.LogInformation("创建上传会话:{SessionId},文件:{FileName},总分片:{TotalChunks}",
sessionId, fileName, totalChunks);
return session;
}
/// <summary>
/// 上传单个分片
/// </summary>
public async Task<ChunkUploadResult> UploadChunkAsync(
string sessionId, int chunkIndex, Stream chunkStream)
{
if (!_sessions.TryGetValue(sessionId, out var session))
return new ChunkUploadResult { Success = false, Error = "会话不存在或已过期" };
if (chunkIndex < 0 || chunkIndex >= session.TotalChunks)
return new ChunkUploadResult { Success = false, Error = "分片索引无效" };
// 如果已上传,直接跳过(支持断点续传)
if (session.UploadedChunks.Contains(chunkIndex))
return new ChunkUploadResult { Success = true, Message = "分片已存在", Skipped = true };
var chunkPath = Path.Combine(session.TempDirectory, $"chunk_{chunkIndex}");
using var fileStream = new FileStream(chunkPath, FileMode.Create);
await chunkStream.CopyToAsync(fileStream);
lock (session.UploadedChunks)
{
session.UploadedChunks.Add(chunkIndex);
}
var progress = (double)session.UploadedChunks.Count / session.TotalChunks * 100;
_logger.LogDebug("分片 {ChunkIndex}/{TotalChunks} 上传完成,进度:{Progress:F1}%",
chunkIndex + 1, session.TotalChunks, progress);
return new ChunkUploadResult
{
Success = true,
Progress = progress,
UploadedChunks = session.UploadedChunks.Count,
TotalChunks = session.TotalChunks,
IsComplete = session.UploadedChunks.Count == session.TotalChunks
};
}
/// <summary>
/// 合并所有分片
/// </summary>
public async Task<string> MergeChunksAsync(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
throw new InvalidOperationException("会话不存在");
if (session.UploadedChunks.Count != session.TotalChunks)
throw new InvalidOperationException(
$"分片不完整:{session.UploadedChunks.Count}/{session.TotalChunks}");
var extension = Path.GetExtension(session.FileName).ToLowerInvariant();
var finalFileName = $"{Guid.NewGuid()}{extension}";
var finalPath = $"uploads/{DateTime.UtcNow:yyyy/MM/dd}/{finalFileName}";
var fullFinalPath = Path.Combine("wwwroot", finalPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullFinalPath)!);
// 按顺序合并分片
using var finalStream = new FileStream(fullFinalPath, FileMode.Create);
for (int i = 0; i < session.TotalChunks; i++)
{
var chunkPath = Path.Combine(session.TempDirectory, $"chunk_{i}");
using var chunkStream = new FileStream(chunkPath, FileMode.Open);
await chunkStream.CopyToAsync(finalStream);
}
// 清理临时文件
try
{
Directory.Delete(session.TempDirectory, recursive: true);
_sessions.TryRemove(sessionId, out _);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "清理临时文件失败:{Path}", session.TempDirectory);
}
_logger.LogInformation("文件合并完成:{FinalPath},大小:{Size} bytes",
finalPath, session.FileSize);
return finalPath;
}
/// <summary>
/// 查询上传进度(断点续传时使用)
/// </summary>
public UploadProgress GetProgress(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
return null;
return new UploadProgress
{
SessionId = sessionId,
UploadedChunks = session.UploadedChunks.ToList(),
TotalChunks = session.TotalChunks,
Progress = (double)session.UploadedChunks.Count / session.TotalChunks * 100
};
}
}
public class UploadSession
{
public string SessionId { get; set; }
public string FileName { get; set; }
public long FileSize { get; set; }
public int ChunkSize { get; set; }
public int TotalChunks { get; set; }
public HashSet<int> UploadedChunks { get; set; }
public DateTime CreatedAt { get; set; }
public string TempDirectory { get; set; }
}
public record ChunkUploadResult
{
public bool Success { get; set; }
public string Error { get; set; }
public string Message { get; set; }
public bool Skipped { get; set; }
public double Progress { get; set; }
public int UploadedChunks { get; set; }
public int TotalChunks { get; set; }
public bool IsComplete { get; set; }
}
public record UploadProgress
{
public string SessionId { get; set; }
public List<int> UploadedChunks { get; set; }
public int TotalChunks { get; set; }
public double Progress { get; set; }
}分片上传 API 端点
/// <summary>
/// 分片上传控制器 — 支持断点续传
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class ChunkUploadController : ControllerBase
{
private readonly ChunkedUploadService _chunkService;
private readonly ILogger<ChunkUploadController> _logger;
public ChunkUploadController(ChunkedUploadService chunkService,
ILogger<ChunkUploadController> logger)
{
_chunkService = chunkService;
_logger = logger;
}
// 步骤1:创建上传会话
[HttpPost("session")]
public IActionResult CreateSession([FromBody] CreateUploadSessionRequest request)
{
var session = _chunkService.CreateSession(
request.FileName, request.FileSize, request.ChunkSize ?? 5 * 1024 * 1024);
return Ok(new
{
session.SessionId,
session.TotalChunks,
session.ChunkSize
});
}
// 步骤2:逐个上传分片
[HttpPost("chunk/{sessionId}/{chunkIndex}")]
public async Task<IActionResult> UploadChunk(
string sessionId, int chunkIndex, IFormFile chunk)
{
using var stream = chunk.OpenReadStream();
var result = await _chunkService.UploadChunkAsync(sessionId, chunkIndex, stream);
if (!result.Success)
return BadRequest(new { result.Error });
if (result.IsComplete)
{
var finalPath = await _chunkService.MergeChunksAsync(sessionId);
return Ok(new { result.Progress, result.IsComplete, FinalPath = finalPath });
}
return Ok(result);
}
// 步骤3:查询进度(断点续传时使用)
[HttpGet("progress/{sessionId}")]
public IActionResult GetProgress(string sessionId)
{
var progress = _chunkService.GetProgress(sessionId);
if (progress == null)
return NotFound("会话不存在");
return Ok(progress);
}
// 步骤4:手动触发合并
[HttpPost("merge/{sessionId}")]
public async Task<IActionResult> Merge(string sessionId)
{
try
{
var finalPath = await _chunkService.MergeChunksAsync(sessionId);
return Ok(new { FinalPath = finalPath });
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
}
public record CreateUploadSessionRequest
{
public string FileName { get; init; }
public long FileSize { get; init; }
public int? ChunkSize { get; init; }
}SignalR 实时进度追踪
上传进度 Hub
/// <summary>
/// SignalR Hub — 实时推送上传进度
/// </summary>
public class UploadProgressHub : Hub
{
private readonly ChunkedUploadService _chunkService;
public UploadProgressHub(ChunkedUploadService chunkService)
{
_chunkService = chunkService;
}
/// <summary>
/// 订阅上传会话进度
/// </summary>
public async Task SubscribeSession(string sessionId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"upload_{sessionId}");
}
/// <summary>
/// 取消订阅
/// </summary>
public async Task UnsubscribeSession(string sessionId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"upload_{sessionId}");
}
}
/// <summary>
/// 带进度推送的分片上传服务
/// </summary>
public class ChunkedUploadServiceWithProgress
{
private readonly ChunkedUploadService _inner;
private readonly IHubContext<UploadProgressHub> _hubContext;
private readonly ILogger<ChunkedUploadServiceWithProgress> _logger;
public async Task<ChunkUploadResult> UploadChunkWithProgressAsync(
string sessionId, int chunkIndex, Stream chunkStream)
{
var result = await _inner.UploadChunkAsync(sessionId, chunkIndex, chunkStream);
// 通过 SignalR 推送进度
await _hubContext.Clients.Group($"upload_{sessionId}")
.SendAsync("UploadProgress", new
{
SessionId = sessionId,
ChunkIndex = chunkIndex,
result.Progress,
result.UploadedChunks,
result.TotalChunks,
result.IsComplete
});
_logger.LogDebug("推送上传进度:{SessionId},分片 {ChunkIndex},进度 {Progress:F1}%",
sessionId, chunkIndex, result.Progress);
return result;
}
}文件存储抽象
统一存储接口
/// <summary>
/// 文件存储抽象接口 — 支持多种后端
/// </summary>
public interface IFileStorageService
{
Task<string> SaveAsync(string path, Stream content, string contentType);
Task<Stream> GetAsync(string path);
Task<bool> DeleteAsync(string path);
Task<bool> ExistsAsync(string path);
Task<string> GetPresignedUrlAsync(string path, TimeSpan expiration);
Task<FileMetadata> GetMetadataAsync(string path);
}
public record FileMetadata
{
public string Path { get; init; }
public long Size { get; init; }
public string ContentType { get; init; }
public DateTime LastModified { get; init; }
public string ETag { get; init; }
}本地文件存储实现
/// <summary>
/// 本地文件系统存储
/// </summary>
public class LocalFileStorageService : IFileStorageService
{
private readonly string _basePath;
private readonly ILogger<LocalFileStorageService> _logger;
public LocalFileStorageService(IConfiguration configuration,
ILogger<LocalFileStorageService> logger)
{
_basePath = configuration["FileStorage:BasePath"] ?? "wwwroot/uploads";
_logger = logger;
Directory.CreateDirectory(_basePath);
}
public async Task<string> SaveAsync(string path, Stream content, string contentType)
{
var fullPath = Path.Combine(_basePath, path);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
using var fileStream = new FileStream(fullPath, FileMode.Create);
await content.CopyToAsync(fileStream);
_logger.LogInformation("文件保存到本地:{Path}", fullPath);
return $"/uploads/{path}";
}
public Task<Stream> GetAsync(string path)
{
var fullPath = Path.Combine(_basePath, path);
if (!File.Exists(fullPath))
throw new FileNotFoundException("文件不存在", fullPath);
Stream stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read);
return Task.FromResult(stream);
}
public Task<bool> DeleteAsync(string path)
{
var fullPath = Path.Combine(_basePath, path);
if (File.Exists(fullPath))
{
File.Delete(fullPath);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
public Task<bool> ExistsAsync(string path)
{
return Task.FromResult(File.Exists(Path.Combine(_basePath, path)));
}
public Task<string> GetPresignedUrlAsync(string path, TimeSpan expiration)
{
return Task.FromResult($"/uploads/{path}");
}
public Task<FileMetadata> GetMetadataAsync(string path)
{
var fullPath = Path.Combine(_basePath, path);
var info = new FileInfo(fullPath);
if (!info.Exists)
throw new FileNotFoundException("文件不存在", fullPath);
return Task.FromResult(new FileMetadata
{
Path = path,
Size = info.Length,
ContentType = GetContentType(fullPath),
LastModified = info.LastWriteTimeUtc,
ETag = info.LastWriteTimeUtc.Ticks.ToString("x")
});
}
private static string GetContentType(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".pdf" => "application/pdf",
".mp4" => "video/mp4",
_ => "application/octet-stream"
};
}
}MinIO 对象存储实现
/// <summary>
/// MinIO 对象存储实现
/// </summary>
public class MinioFileStorageService : IFileStorageService
{
private readonly MinioClient _client;
private readonly string _bucketName;
private readonly ILogger<MinioFileStorageService> _logger;
public MinioFileStorageService(IConfiguration configuration,
ILogger<MinioFileStorageService> logger)
{
_logger = logger;
_bucketName = configuration["MinIO:BucketName"] ?? "uploads";
_client = new MinioClient()
.WithEndpoint(configuration["MinIO:Endpoint"] ?? "localhost:9000")
.WithCredentials(
configuration["MinIO:AccessKey"],
configuration["MinIO:SecretKey"])
.Build();
}
public async Task<string> SaveAsync(string path, Stream content, string contentType)
{
await EnsureBucketExistsAsync();
var args = new PutObjectArgs()
.WithBucket(_bucketName)
.WithObject(path)
.WithStreamData(content)
.WithObjectSize(content.Length)
.WithContentType(contentType);
await _client.PutObjectAsync(args);
_logger.LogInformation("文件保存到 MinIO:{Bucket}/{Path}", _bucketName, path);
return $"/files/{path}";
}
public async Task<Stream> GetAsync(string path)
{
var memoryStream = new MemoryStream();
var args = new GetObjectArgs()
.WithBucket(_bucketName)
.WithObject(path)
.WithCallbackStream(stream => stream.CopyTo(memoryStream));
await _client.GetObjectAsync(args);
memoryStream.Position = 0;
return memoryStream;
}
public async Task<bool> DeleteAsync(string path)
{
await _client.RemoveObjectAsync(
new RemoveObjectArgs().WithBucket(_bucketName).WithObject(path));
return true;
}
public async Task<bool> ExistsAsync(string path)
{
try
{
await _client.StatObjectAsync(
new StatObjectArgs().WithBucket(_bucketName).WithObject(path));
return true;
}
catch { return false; }
}
public async Task<string> GetPresignedUrlAsync(string path, TimeSpan expiration)
{
return await _client.PresignedGetObjectAsync(
new PresignedGetObjectArgs()
.WithBucket(_bucketName)
.WithObject(path)
.WithExpiry((int)expiration.TotalSeconds));
}
public async Task<FileMetadata> GetMetadataAsync(string path)
{
var stat = await _client.StatObjectAsync(
new StatObjectArgs().WithBucket(_bucketName).WithObject(path));
return new FileMetadata
{
Path = path,
Size = stat.Size,
ContentType = stat.ContentType,
LastModified = stat.LastModified.ToUniversalTime(),
ETag = stat.ETag
};
}
private async Task EnsureBucketExistsAsync()
{
if (!await _client.BucketExistsAsync(
new BucketExistsArgs().WithBucket(_bucketName)))
{
await _client.MakeBucketAsync(
new MakeBucketArgs().WithBucket(_bucketName));
}
}
}DI 注册存储服务
/// <summary>
/// 根据配置选择存储后端
/// </summary>
public static class FileStorageExtensions
{
public static IServiceCollection AddFileStorage(
this IServiceCollection services, IConfiguration configuration)
{
var provider = configuration["FileStorage:Provider"] ?? "Local";
switch (provider.ToLowerInvariant())
{
case "minio":
services.AddSingleton<IFileStorageService, MinioFileStorageService>();
break;
case "aliyunoss":
services.AddSingleton<IFileStorageService, AliyunOssStorageService>();
break;
case "local":
default:
services.AddSingleton<IFileStorageService, LocalFileStorageService>();
break;
}
services.AddSingleton<ChunkedUploadService>();
return services;
}
}
// Program.cs 中使用
// builder.Services.AddFileStorage(builder.Configuration);文件下载
普通下载与 Range 请求
/// <summary>
/// 文件下载 — 支持 Range 请求(断点续传下载)
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class DownloadController : ControllerBase
{
private readonly IFileStorageService _storage;
public DownloadController(IFileStorageService storage)
{
_storage = storage;
}
/// <summary>
/// 普通下载
/// </summary>
[HttpGet("{*filePath}")]
public async Task<IActionResult> Download(string filePath)
{
if (!await _storage.ExistsAsync(filePath))
return NotFound("文件不存在");
var stream = await _storage.GetAsync(filePath);
var metadata = await _storage.GetMetadataAsync(filePath);
Response.Headers.Add("Content-Disposition",
$"attachment; filename=\"{Uri.EscapeDataString(Path.GetFileName(filePath))}\"");
Response.Headers.Add("Accept-Ranges", "bytes");
Response.Headers.Add("ETag", $"\"{metadata.ETag}\"");
Response.Headers.Add("Last-Modified", metadata.LastModified.ToString("R"));
return File(stream, metadata.ContentType);
}
/// <summary>
/// 支持 Range 请求的下载(断点续传下载、视频拖动进度条)
/// </summary>
[HttpGet("range/{*filePath}")]
public async Task<IActionResult> DownloadWithRange(string filePath)
{
if (!await _storage.ExistsAsync(filePath))
return NotFound();
var metadata = await _storage.GetMetadataAsync(filePath);
var totalSize = metadata.Size;
var rangeHeader = Request.Headers.Range;
if (string.IsNullOrEmpty(rangeHeader))
{
// 无 Range 头,返回完整文件
var fullStream = await _storage.GetAsync(filePath);
Response.Headers.Append("Accept-Ranges", "bytes");
return File(fullStream, metadata.ContentType);
}
// 解析 Range: bytes=start-end
var range = ParseRangeHeader(rangeHeader.ToString(), totalSize);
if (range == null)
{
Response.Headers.Append("Content-Range", $"bytes */{totalSize}");
return StatusCode(416); // Range Not Satisfiable
}
var partialStream = await _storage.GetAsync(filePath);
partialStream.Position = range.Value.Start;
var length = range.Value.End - range.Value.Start + 1;
var buffer = new byte[length];
await partialStream.ReadAsync(buffer, 0, (int)length);
Response.StatusCode = 206; // Partial Content
Response.Headers.Append("Content-Range",
$"bytes {range.Value.Start}-{range.Value.End}/{totalSize}");
Response.Headers.Append("Content-Length", length.ToString());
Response.Headers.Append("Accept-Ranges", "bytes");
return File(new MemoryStream(buffer), metadata.ContentType);
}
private (long Start, long End)? ParseRangeHeader(string rangeHeader, long totalSize)
{
if (!rangeHeader.StartsWith("bytes="))
return null;
var rangeSpec = rangeHeader["bytes=".Length..];
var parts = rangeSpec.Split('-');
if (parts.Length != 2) return null;
long start, end;
if (string.IsNullOrEmpty(parts[0]))
{
var suffixLength = long.Parse(parts[1]);
start = Math.Max(0, totalSize - suffixLength);
end = totalSize - 1;
}
else if (string.IsNullOrEmpty(parts[1]))
{
start = long.Parse(parts[0]);
end = totalSize - 1;
}
else
{
start = long.Parse(parts[0]);
end = long.Parse(parts[1]);
}
if (start < 0 || start >= totalSize || end < start)
return null;
end = Math.Min(end, totalSize - 1);
return (start, end);
}
}安全防护
多层安全校验服务
/// <summary>
/// 文件上传安全校验服务 — 扩展名 + MIME + 魔数 + 病毒扫描
/// </summary>
public class FileUploadSecurityService
{
private readonly ILogger<FileUploadSecurityService> _logger;
// 扩展名白名单
private static readonly HashSet<string> AllowedExtensions =
new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".txt", ".csv", ".json", ".xml",
".zip", ".rar", ".7z"
};
// 危险扩展名黑名单
private static readonly HashSet<string> DangerousExtensions =
new(StringComparer.OrdinalIgnoreCase)
{
".exe", ".bat", ".cmd", ".ps1", ".vbs", ".js", ".msi",
".com", ".scr", ".dll", ".sys", ".reg", ".inf"
};
// 文件魔数(Magic Bytes)映射
private static readonly Dictionary<string, byte[]> FileSignatures = new()
{
{ ".jpg", new byte[] { 0xFF, 0xD8, 0xFF } },
{ ".png", new byte[] { 0x89, 0x50, 0x4E, 0x47 } },
{ ".gif", new byte[] { 0x47, 0x49, 0x46, 0x38 } },
{ ".pdf", new byte[] { 0x25, 0x50, 0x44, 0x46 } },
{ ".zip", new byte[] { 0x50, 0x4B, 0x03, 0x04 } },
{ ".doc", new byte[] { 0xD0, 0xCF, 0x11, 0xE0 } },
{ ".docx", new byte[] { 0x50, 0x4B, 0x03, 0x04 } }
};
/// <summary>
/// 综合安全校验
/// </summary>
public async Task<SecurityCheckResult> ValidateAsync(IFormFile file)
{
var result = new SecurityCheckResult();
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
// 1. 危险扩展名检查
if (DangerousExtensions.Contains(extension))
{
result.IsValid = false;
result.Reason = $"危险文件类型:{extension}";
_logger.LogWarning("上传危险文件被拦截:{FileName}", file.FileName);
return result;
}
// 2. 扩展名白名单
if (!AllowedExtensions.Contains(extension))
{
result.IsValid = false;
result.Reason = $"不允许的文件类型:{extension}";
return result;
}
// 3. MIME 类型校验
if (!ValidateMimeType(file.ContentType, extension))
{
result.IsValid = false;
result.Reason = $"MIME 类型与扩展名不匹配";
return result;
}
// 4. 文件魔数校验(防止扩展名伪装)
if (FileSignatures.TryGetValue(extension, out var expectedSignature))
{
using var stream = file.OpenReadStream();
var buffer = new byte[expectedSignature.Length];
await stream.ReadAsync(buffer, 0, buffer.Length);
if (!buffer.SequenceEqual(expectedSignature))
{
result.IsValid = false;
result.Reason = "文件内容与扩展名不匹配(魔数校验失败)";
_logger.LogWarning("文件魔数校验失败:{FileName}", file.FileName);
return result;
}
}
// 5. 文件名安全检查
var fileName = Path.GetFileName(file.FileName);
if (fileName != file.FileName || fileName.Contains(".."))
{
result.IsValid = false;
result.Reason = "文件名包含非法字符";
return result;
}
result.IsValid = true;
return result;
}
private static bool ValidateMimeType(string mimeType, string extension)
{
var expectedMime = extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".pdf" => "application/pdf",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
_ => null
};
if (expectedMime == null) return true;
return string.Equals(mimeType, expectedMime, StringComparison.OrdinalIgnoreCase);
}
}
public record SecurityCheckResult
{
public bool IsValid { get; set; }
public string Reason { get; set; }
}病毒扫描集成
/// <summary>
/// 病毒扫描服务 — 集成 ClamAV
/// </summary>
public class VirusScanService
{
private readonly ILogger<VirusScanService> _logger;
public VirusScanService(ILogger<VirusScanService> logger)
{
_logger = logger;
}
/// <summary>
/// 异步扫描文件流
/// </summary>
public async Task<VirusScanResult> ScanAsync(Stream fileStream, string fileName)
{
try
{
using var client = new TcpClient();
await client.ConnectAsync("localhost", 3310);
using var stream = client.GetStream();
var writer = new StreamWriter(stream) { AutoFlush = true };
var reader = new StreamReader(stream);
// 发送 INSTREAM 扫描指令
await writer.WriteLineAsync("zINSTREAM");
var buffer = new byte[8192];
int bytesRead;
fileStream.Position = 0;
while ((bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
var lengthBytes = BitConverter.GetBytes(bytesRead);
Array.Reverse(lengthBytes); // 网络字节序
await stream.WriteAsync(lengthBytes, 0, 4);
await stream.WriteAsync(buffer, 0, bytesRead);
}
// 结束标记
var endMarker = BitConverter.GetBytes(0);
Array.Reverse(endMarker);
await stream.WriteAsync(endMarker, 0, 4);
var response = await reader.ReadLineAsync();
var isClean = response?.Contains("OK") == true;
_logger.LogInformation("病毒扫描完成:{FileName},结果:{Result}",
fileName, isClean ? "安全" : "发现威胁");
return new VirusScanResult
{
IsClean = isClean,
ScanResult = response,
FileName = fileName
};
}
catch (Exception ex)
{
_logger.LogError(ex, "病毒扫描服务异常:{FileName}", fileName);
return new VirusScanResult
{
IsClean = false,
ScanResult = $"扫描服务异常:{ex.Message}",
FileName = fileName
};
}
}
}
public record VirusScanResult
{
public bool IsClean { get; set; }
public string ScanResult { get; set; }
public string FileName { get; set; }
}CDN 集成
CDN 缓存与刷新
/// <summary>
/// CDN 集成服务
/// </summary>
public class CdnIntegrationService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<CdnIntegrationService> _logger;
public CdnIntegrationService(IHttpClientFactory httpClientFactory,
IConfiguration configuration, ILogger<CdnIntegrationService> logger)
{
_httpClientFactory = httpClientFactory;
_configuration = configuration;
_logger = logger;
}
/// <summary>
/// 获取 CDN 加速 URL
/// </summary>
public string GetCdnUrl(string originUrl)
{
var cdnDomain = _configuration["CDN:Domain"];
if (string.IsNullOrEmpty(cdnDomain)) return originUrl;
var uri = new Uri(originUrl);
return $"{cdnDomain}{uri.AbsolutePath}";
}
/// <summary>
/// 刷新 CDN 缓存(文件更新后调用)
/// </summary>
public async Task RefreshCacheAsync(string filePath)
{
var cdnApiUrl = _configuration["CDN:RefreshApiUrl"];
if (string.IsNullOrEmpty(cdnApiUrl)) return;
try
{
var client = _httpClientFactory.CreateClient("CDN");
var response = await client.PostAsync($"{cdnApiUrl}/refresh",
new StringContent(
System.Text.Json.JsonSerializer.Serialize(new { paths = new[] { filePath } }),
System.Text.Encoding.UTF8, "application/json"));
if (response.IsSuccessStatusCode)
_logger.LogInformation("CDN 缓存刷新成功:{Path}", filePath);
else
_logger.LogWarning("CDN 缓存刷新失败:{Path},状态码:{StatusCode}",
filePath, response.StatusCode);
}
catch (Exception ex)
{
_logger.LogError(ex, "CDN 缓存刷新异常:{Path}", filePath);
}
}
}
/// <summary>
/// CDN 缓存中间件 — 为静态文件添加缓存头
/// </summary>
public class CdnCacheMiddleware
{
private readonly RequestDelegate _next;
public CdnCacheMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Path.StartsWithSegments("/uploads"))
{
// 公共缓存 30 天
context.Response.Headers.Append("Cache-Control", "public, max-age=2592000");
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
}
await _next(context);
}
}上传方式对比
| 方式 | 适用大小 | 内存占用 | 支持续传 | 复杂度 |
|---|---|---|---|---|
| IFormFile | < 100MB | 高 | 否 | 低 |
| 流式上传 | 任意大小 | 低 | 否 | 中 |
| 分片上传 | 大文件 | 低 | 是 | 高 |
| OSS 直传 | 任意大小 | 无 | 是 | 中 |
优点
缺点
性能注意事项
- 大文件使用流式上传,设置 BufferBody = false
- 分片上传并行度建议 3-5 个并发,避免磁盘 IO 争抢
- 合并分片时使用顺序写入,避免磁盘随机 IO
- OSS 预签名 URL 直传绕过应用服务器,减轻后端压力
- 启用 HTTP/2 多路复用,减少连接开销
- 定期清理临时文件,避免磁盘空间耗尽
- 监控上传成功率、平均耗时和存储空间使用率
总结
文件上传下载是 Web 应用的基础能力,但要做到生产级可靠需要考虑很多细节。小文件用 IFormFile 简单直接;大文件必须用流式或分片上传避免内存溢出;断点续传是用户体验的基本要求;存储层抽象让系统可以灵活切换后端;安全校验是必须的防线。核心原则:永远不要信任用户上传的文件。
关键知识点
- IFormFile 适合小文件,流式上传适合大文件,分片上传支持断点续传
- Range 请求是 HTTP 协议标准,支持下载断点续传和视频拖动进度条
- 文件安全校验至少三层:扩展名白名单 + MIME 校验 + 文件魔数校验
- 存储抽象接口让本地存储和对象存储可以随时切换
- CDN 通过 Cache-Control 头和预签名 URL 实现加速
项目落地视角
- 上传前先确认存储后端、文件大小上限和安全策略
- 分片上传的前端 SDK 要和后端 API 配合设计
- 病毒扫描部署为独立微服务,避免影响上传性能
- 监控上传成功率、平均耗时和存储空间使用率
- 冷热数据分层,定期清理过期临时文件
常见误区
- 用 IFormFile 处理大文件导致内存溢出
- 只校验扩展名不校验文件内容,被伪装文件攻击
- 不限制并发上传数导致服务器资源耗尽
- 忘记清理分片上传的临时文件导致磁盘满
- 分片合并不做完整性校验,合并后文件可能损坏
- Range 下载没有正确设置 206 状态码和 Content-Range 头
进阶路线
- 学习对象存储的 multipart upload 协议
- 了解 WebAssembly + File API 浏览器端前处理
- 研究分布式文件系统(FastDFS、SeaweedFS)
- 探索 gRPC 流式传输的文件上传方案
- 学习内容审核(图片鉴黄、文档涉密检测)
适用场景
- 用户头像、证件照等小文件上传
- 视频平台的大文件视频上传
- 企业文档管理系统的文件上传下载
- 数据导入导出的批量文件处理
- 物联网设备的固件和日志文件上传
落地建议
- 小文件直接用 IFormFile,配置好大小限制和安全校验即可
- 超过 100MB 的文件必须使用分片上传
- 生产环境使用对象存储(MinIO/OSS),不要存本地磁盘
- 为上传接口配置独立的限流策略
- 实现文件清理定时任务,删除过期临时文件和孤立文件
排错清单
- 上传 413 错误:检查 Kestrel MaxRequestBodySize、IIS 限制、FormOptions
- 上传超时:检查 Kestrel RequestHeadersTimeout、反向代理超时配置
- 内存飙升:检查是否使用了 IFormFile 缓冲模式,改用流式上传
- 文件损坏:检查分片合并顺序、流是否正确关闭
- MIME 校验失败:检查浏览器发送的 Content-Type 是否正确
- Range 下载 416 错误:检查 Range 头解析逻辑
复盘问题
- 文件上传接口的 P99 延迟是多少?瓶颈在哪里?
- 分片上传失败后的重试策略是否合理?
- 存储空间增长趋势如何?是否有清理策略?
- 安全校验是否覆盖了所有攻击向量?
- CDN 命中率如何?是否存在回源过多的问题?
