文件上传与下载
文件上传
1. 单文件上传
- 基本实现
- 使用 DTO
[ApiController]
[Route("api/[controller]")]
public class FileUploadController : ControllerBase
{
private readonly IWebHostEnvironment _environment;
private readonly ILogger<FileUploadController> _logger;
public FileUploadController(
IWebHostEnvironment environment,
ILogger<FileUploadController> logger)
{
_environment = environment;
_logger = logger;
}
[HttpPost("upload")]
[RequestSizeLimit(10 * 1024 * 1024)] // 限制 10MB
public async Task<IActionResult> Upload(IFormFile file)
{
if (file == null || file.Length == 0)
{
return BadRequest("请选择文件");
}
// 验证文件类型
var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".pdf" };
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!allowedExtensions.Contains(extension))
{
return BadRequest("不支持的文件类型");
}
// 生成唯一文件名
var fileName = $"{Guid.NewGuid()}{extension}";
var uploadPath = Path.Combine(_environment.WebRootPath, "uploads");
// 确保目录存在
if (!Directory.Exists(uploadPath))
{
Directory.CreateDirectory(uploadPath);
}
var filePath = Path.Combine(uploadPath, fileName);
// 保存文件
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
_logger.LogInformation("文件上传成功: {FileName}", fileName);
return Ok(new
{
FileName = fileName,
OriginalName = file.FileName,
Size = file.Length,
ContentType = file.ContentType,
Url = $"/uploads/{fileName}"
});
}
}
public class FileUploadDto
{
public IFormFile File { get; set; } = null!;
public string? Description { get; set; }
public string? Category { get; set; }
}
[HttpPost("upload-with-metadata")]
public async Task<IActionResult> UploadWithMetadata([FromForm] FileUploadDto dto)
{
if (dto.File == null || dto.File.Length == 0)
{
return BadRequest("请选择文件");
}
// 处理文件和元数据
var fileName = await SaveFileAsync(dto.File);
// 保存到数据库
var fileRecord = new FileEntity
{
FileName = fileName,
OriginalName = dto.File.FileName,
Description = dto.Description,
Category = dto.Category,
Size = dto.File.Length,
ContentType = dto.File.ContentType,
UploadedAt = DateTime.UtcNow
};
// await _dbContext.Files.AddAsync(fileRecord);
// await _dbContext.SaveChangesAsync();
return Ok(new
{
Id = fileRecord.Id,
FileName = fileName,
Message = "文件上传成功"
});
}
private async Task<string> SaveFileAsync(IFormFile file)
{
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
var uploadPath = Path.Combine(_environment.WebRootPath, "uploads");
Directory.CreateDirectory(uploadPath);
var filePath = Path.Combine(uploadPath, fileName);
using var stream = new FileStream(filePath, FileMode.Create);
await file.CopyToAsync(stream);
return fileName;
}
2. 多文件上传
[HttpPost("upload-multiple")]
[RequestSizeLimit(50 * 1024 * 1024)] // 限制总大小 50MB
public async Task<IActionResult> UploadMultiple(List<IFormFile> files)
{
if (files == null || files.Count == 0)
{
return BadRequest("请选择至少一个文件");
}
if (files.Count > 10)
{
return BadRequest("最多只能上传 10 个文件");
}
var uploadedFiles = new List<FileUploadResult>();
foreach (var file in files)
{
if (file.Length > 0)
{
try
{
var fileName = await SaveFileAsync(file);
uploadedFiles.Add(new FileUploadResult
{
FileName = fileName,
OriginalName = file.FileName,
Size = file.Length,
Success = true
});
}
catch (Exception ex)
{
_logger.LogError(ex, "文件上传失败: {FileName}", file.FileName);
uploadedFiles.Add(new FileUploadResult
{
OriginalName = file.FileName,
Success = false,
Error = "上传失败"
});
}
}
}
return Ok(new
{
TotalFiles = files.Count,
SuccessCount = uploadedFiles.Count(f => f.Success),
FailedCount = uploadedFiles.Count(f => !f.Success),
Files = uploadedFiles
});
}
public class FileUploadResult
{
public string? FileName { get; set; }
public string OriginalName { get; set; } = string.Empty;
public long Size { get; set; }
public bool Success { get; set; }
public string? Error { get; set; }
}
3. 大文件分片上传
public class ChunkUploadDto
{
public IFormFile File { get; set; } = null!;
public int ChunkIndex { get; set; }
public int TotalChunks { get; set; }
public string FileIdentifier { get; set; } = string.Empty;
}
[HttpPost("upload-chunk")]
public async Task<IActionResult> UploadChunk([FromForm] ChunkUploadDto dto)
{
var tempPath = Path.Combine(_environment.WebRootPath, "temp", dto.FileIdentifier);
Directory.CreateDirectory(tempPath);
// 保存分片
var chunkPath = Path.Combine(tempPath, $"chunk_{dto.ChunkIndex}");
using (var stream = new FileStream(chunkPath, FileMode.Create))
{
await dto.File.CopyToAsync(stream);
}
_logger.LogInformation(
"分片上传: {ChunkIndex}/{TotalChunks}",
dto.ChunkIndex + 1,
dto.TotalChunks);
// 检查是否所有分片都已上传
var uploadedChunks = Directory.GetFiles(tempPath, "chunk_*").Length;
if (uploadedChunks == dto.TotalChunks)
{
// 合并分片
var fileName = await MergeChunksAsync(dto.FileIdentifier, dto.TotalChunks);
// 清理临时文件
Directory.Delete(tempPath, true);
return Ok(new
{
IsComplete = true,
FileName = fileName,
Message = "文件上传完成"
});
}
return Ok(new
{
IsComplete = false,
UploadedChunks = uploadedChunks,
TotalChunks = dto.TotalChunks
});
}
private async Task<string> MergeChunksAsync(string fileIdentifier, int totalChunks)
{
var tempPath = Path.Combine(_environment.WebRootPath, "temp", fileIdentifier);
var fileName = $"{Guid.NewGuid()}.bin";
var uploadPath = Path.Combine(_environment.WebRootPath, "uploads");
Directory.CreateDirectory(uploadPath);
var finalPath = Path.Combine(uploadPath, fileName);
using (var finalStream = new FileStream(finalPath, FileMode.Create))
{
for (int i = 0; i < totalChunks; i++)
{
var chunkPath = Path.Combine(tempPath, $"chunk_{i}");
using var chunkStream = new FileStream(chunkPath, FileMode.Open);
await chunkStream.CopyToAsync(finalStream);
}
}
_logger.LogInformation("分片合并完成: {FileName}", fileName);
return fileName;
}
4. 文件验证
public class FileValidator
{
private static readonly Dictionary<string, List<byte[]>> _fileSignatures = new()
{
{ ".jpg", new List<byte[]>
{
new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 },
new byte[] { 0xFF, 0xD8, 0xFF, 0xE1 },
new byte[] { 0xFF, 0xD8, 0xFF, 0xE8 }
}
},
{ ".png", new List<byte[]>
{
new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }
}
},
{ ".pdf", new List<byte[]>
{
new byte[] { 0x25, 0x50, 0x44, 0x46 }
}
}
};
public static bool IsValidFile(IFormFile file)
{
if (file == null || file.Length == 0)
return false;
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!_fileSignatures.ContainsKey(extension))
return false;
using var reader = new BinaryReader(file.OpenReadStream());
var signatures = _fileSignatures[extension];
var headerBytes = reader.ReadBytes(signatures.Max(s => s.Length));
return signatures.Any(signature =>
headerBytes.Take(signature.Length).SequenceEqual(signature));
}
public static bool IsValidSize(IFormFile file, long maxSizeInBytes)
{
return file != null && file.Length > 0 && file.Length <= maxSizeInBytes;
}
public static bool HasValidExtension(IFormFile file, string[] allowedExtensions)
{
if (file == null)
return false;
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
return allowedExtensions.Contains(extension);
}
}
// 使用验证器
[HttpPost("upload-validated")]
public async Task<IActionResult> UploadValidated(IFormFile file)
{
var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".pdf" };
var maxSize = 5 * 1024 * 1024; // 5MB
if (!FileValidator.HasValidExtension(file, allowedExtensions))
{
return BadRequest("不支持的文件类型");
}
if (!FileValidator.IsValidSize(file, maxSize))
{
return BadRequest("文件大小超出限制(最大 5MB)");
}
if (!FileValidator.IsValidFile(file))
{
return BadRequest("文件内容验证失败");
}
var fileName = await SaveFileAsync(file);
return Ok(new { FileName = fileName });
}
文件下载
1. 基本下载
- 物理文件
- 流式下载
- 内联显示
[HttpGet("download/{fileName}")]
public IActionResult Download(string fileName)
{
// 验证文件名,防止路径遍历攻击
if (fileName.Contains("..") || fileName.Contains("/") || fileName.Contains("\\"))
{
return BadRequest("无效的文件名");
}
var filePath = Path.Combine(_environment.WebRootPath, "uploads", fileName);
if (!System.IO.File.Exists(filePath))
{
return NotFound("文件不存在");
}
var memory = new MemoryStream();
using (var stream = new FileStream(filePath, FileMode.Open))
{
stream.CopyTo(memory);
}
memory.Position = 0;
var contentType = GetContentType(fileName);
return File(memory, contentType, fileName);
}
private string GetContentType(string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
return extension switch
{
".pdf" => "application/pdf",
".jpg" => "image/jpeg",
".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".txt" => "text/plain",
".json" => "application/json",
".xml" => "application/xml",
".zip" => "application/zip",
_ => "application/octet-stream"
};
}
[HttpGet("download-stream/{fileName}")]
public IActionResult DownloadStream(string fileName)
{
if (fileName.Contains("..") || fileName.Contains("/") || fileName.Contains("\\"))
{
return BadRequest("无效的文件名");
}
var filePath = Path.Combine(_environment.WebRootPath, "uploads", fileName);
if (!System.IO.File.Exists(filePath))
{
return NotFound("文件不存在");
}
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
var contentType = GetContentType(fileName);
// 流式下载,适合大文件
return File(stream, contentType, fileName, enableRangeProcessing: true);
}
[HttpGet("view/{fileName}")]
public IActionResult ViewFile(string fileName)
{
if (fileName.Contains("..") || fileName.Contains("/") || fileName.Contains("\\"))
{
return BadRequest("无效的文件名");
}
var filePath = Path.Combine(_environment.WebRootPath, "uploads", fileName);
if (!System.IO.File.Exists(filePath))
{
return NotFound("文件不存在");
}
var memory = new MemoryStream();
using (var stream = new FileStream(filePath, FileMode.Open))
{
stream.CopyTo(memory);
}
memory.Position = 0;
var contentType = GetContentType(fileName);
// 在浏览器中显示而不是下载
Response.Headers.Add("Content-Disposition", $"inline; filename=\"{fileName}\"");
return File(memory, contentType);
}
2. 从数据库下载
public class FileEntity
{
public int Id { get; set; }
public string FileName { get; set; } = string.Empty;
public string OriginalName { get; set; } = string.Empty;
public byte[] Content { get; set; } = Array.Empty<byte>();
public string ContentType { get; set; } = string.Empty;
public DateTime UploadedAt { get; set; }
}
[HttpGet("download-from-db/{id}")]
public async Task<IActionResult> DownloadFromDatabase(int id)
{
var file = await _dbContext.Files.FindAsync(id);
if (file == null)
{
return NotFound("文件不存在");
}
return File(file.Content, file.ContentType, file.OriginalName);
}
// 上传到数据库
[HttpPost("upload-to-db")]
public async Task<IActionResult> UploadToDatabase(IFormFile file)
{
if (file == null || file.Length == 0)
{
return BadRequest("请选择文件");
}
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream);
var fileEntity = new FileEntity
{
FileName = file.FileName,
OriginalName = file.FileName,
Content = memoryStream.ToArray(),
ContentType = file.ContentType,
UploadedAt = DateTime.UtcNow
};
_dbContext.Files.Add(fileEntity);
await _dbContext.SaveChangesAsync();
return Ok(new { Id = fileEntity.Id, Message = "文件上传成功" });
}
3. 生成文件并下载
[HttpGet("export/users")]
public IActionResult ExportUsers()
{
// 获取用户数据
var users = GetUsers();
// 生成 CSV
var csv = GenerateCsv(users);
var bytes = Encoding.UTF8.GetBytes(csv);
var fileName = $"users_{DateTime.Now:yyyyMMddHHmmss}.csv";
return File(bytes, "text/csv", fileName);
}
private string GenerateCsv(List<User> users)
{
var sb = new StringBuilder();
sb.AppendLine("ID,Name,Email,CreatedAt");
foreach (var user in users)
{
sb.AppendLine($"{user.Id},{user.Name},{user.Email},{user.CreatedAt:yyyy-MM-dd}");
}
return sb.ToString();
}
// Excel 导出(使用 EPPlus)
[HttpGet("export/users/excel")]
public IActionResult ExportUsersToExcel()
{
var users = GetUsers();
using var package = new ExcelPackage();
var worksheet = package.Workbook.Worksheets.Add("Users");
// 添加表头
worksheet.Cells[1, 1].Value = "ID";
worksheet.Cells[1, 2].Value = "Name";
worksheet.Cells[1, 3].Value = "Email";
worksheet.Cells[1, 4].Value = "Created At";
// 添加数据
for (int i = 0; i < users.Count; i++)
{
var user = users[i];
worksheet.Cells[i + 2, 1].Value = user.Id;
worksheet.Cells[i + 2, 2].Value = user.Name;
worksheet.Cells[i + 2, 3].Value = user.Email;
worksheet.Cells[i + 2, 4].Value = user.CreatedAt.ToString("yyyy-MM-dd");
}
var stream = new MemoryStream();
package.SaveAs(stream);
stream.Position = 0;
var fileName = $"users_{DateTime.Now:yyyyMMddHHmmss}.xlsx";
return File(stream, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);
}
云存储集成
Azure Blob Storage
dotnet add package Azure.Storage.Blobs
public class AzureBlobService
{
private readonly BlobServiceClient _blobServiceClient;
private readonly string _containerName;
public AzureBlobService(IConfiguration configuration)
{
var connectionString = configuration["AzureStorage:ConnectionString"];
_blobServiceClient = new BlobServiceClient(connectionString);
_containerName = configuration["AzureStorage:ContainerName"] ?? "uploads";
}
public async Task<string> UploadAsync(IFormFile file)
{
var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
await containerClient.CreateIfNotExistsAsync();
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
var blobClient = containerClient.GetBlobClient(fileName);
await blobClient.UploadAsync(file.OpenReadStream(), new BlobHttpHeaders
{
ContentType = file.ContentType
});
return blobClient.Uri.ToString();
}
public async Task<Stream> DownloadAsync(string fileName)
{
var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
var blobClient = containerClient.GetBlobClient(fileName);
var download = await blobClient.DownloadAsync();
return download.Value.Content;
}
public async Task DeleteAsync(string fileName)
{
var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
var blobClient = containerClient.GetBlobClient(fileName);
await blobClient.DeleteIfExistsAsync();
}
}
// 使用
[HttpPost("upload-to-azure")]
public async Task<IActionResult> UploadToAzure(IFormFile file, [FromServices] AzureBlobService blobService)
{
if (file == null || file.Length == 0)
{
return BadRequest("请选择文件");
}
var url = await blobService.UploadAsync(file);
return Ok(new { Url = url });
}
配置限制
Program.cs
var builder = WebApplication.CreateBuilder(args);
// 配置 Kestrel 限制
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 100 * 1024 * 1024; // 100MB
});
// 配置 FormOptions
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 100 * 1024 * 1024; // 100MB
options.ValueLengthLimit = int.MaxValue;
options.MultipartHeadersLengthLimit = int.MaxValue;
});
appsettings.json
{
"FileUpload": {
"MaxFileSize": 10485760,
"AllowedExtensions": [".jpg", ".jpeg", ".png", ".pdf"],
"UploadPath": "uploads"
}
}
最佳实践
最佳实践清单
1. 安全性
// ✅ 好:验证文件类型和内容
public async Task<IActionResult> Upload(IFormFile file)
{
// 验证扩展名
var allowedExtensions = new[] { ".jpg", ".png" };
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!allowedExtensions.Contains(extension))
return BadRequest("不支持的文件类型");
// 验证文件签名
if (!FileValidator.IsValidFile(file))
return BadRequest("文件内容验证失败");
// 生成新文件名,不使用原始文件名
var fileName = $"{Guid.NewGuid()}{extension}";
// 保存文件
}
// ❌ 差:直接使用原始文件名
var filePath = Path.Combine(uploadPath, file.FileName); // 危险!
2. 大文件处理
// ✅ 好:流式处理
[HttpPost("upload-large")]
[DisableRequestSizeLimit]
public async Task<IActionResult> UploadLarge()
{
var file = Request.Form.Files[0];
using var stream = new FileStream(filePath, FileMode.Create);
await file.CopyToAsync(stream); // 流式复制,不占用大量内存
return Ok();
}
// ❌ 差:一次性加载到内存
var bytes = new byte[file.Length];
await file.OpenReadStream().ReadAsync(bytes); // 大文件会导致内存溢出
3. 错误处理
// ✅ 好:完善的错误处理
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file)
{
try
{
// 验证
if (file == null || file.Length == 0)
return BadRequest("请选择文件");
// 保存
var fileName = await SaveFileAsync(file);
return Ok(new { FileName = fileName });
}
catch (IOException ex)
{
_logger.LogError(ex, "文件保存失败");
return StatusCode(500, "文件保存失败");
}
catch (Exception ex)
{
_logger.LogError(ex, "上传失败");
return StatusCode(500, "上传失败");
}
}
4. 性能优化
// ✅ 好:使用缓冲
[HttpGet("download/{fileName}")]
public async Task<IActionResult> Download(string fileName)
{
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
return File(stream, contentType, fileName, enableRangeProcessing: true);
}
// ✅ 好:启用范围请求支持(断点续传)
return File(stream, contentType, fileName, enableRangeProcessing: true);
总结
关键要点
- 始终验证文件类型和大小
- 生成唯一文件名,不使用原始文件名
- 大文件使用流式处理或分片上传
- 配置合理的文件大小限制
- 使用云存储服务存储大量文件
- 启用范围请求支持断点续传
- 记录日志便于问题排查