ASP.NET Core文件分片上传
1.后端实现
服务定义
using System.Security.Cryptography;
using dotnet_start.Model.CusException;
using dotnet_start.Model.Request;
using dotnet_start.Model.Response;
using Path = System.IO.Path;namespace dotnet_start.Services;/// <summary>
/// 分片上传服务
/// </summary>
public class ChunkUploadService : BaseService<ChunkUploadService>
{private readonly string _chunkDir;private readonly string _finalDir;public ChunkUploadService(ILogger<ChunkUploadService> logger, IConfiguration configuration) : base(logger, configuration){_chunkDir = configuration["Upload:ChunkDir"] ?? "uploads/chunk/";_finalDir = configuration["Upload:FinalDir"] ?? "uploads/final/";}public ChunkUploadInitResponse InitUpload(ChunkUploadInitRequest request){var uuid = Guid.NewGuid().ToString("D");return new ChunkUploadInitResponse(uuid, request.FileMD5, request.FileSize);}public async Task UploadChunkAsync(ChunkUploadStartRequest request){var chunkDir = Path.Combine(Directory.GetCurrentDirectory(), _chunkDir, $"{request.FileMD5}_{request.UploadId}");if (!Directory.Exists(chunkDir)){Directory.CreateDirectory(chunkDir);}var chunkFilePath = Path.Combine(chunkDir, $"chunk_{request.Index}.tmp");if (File.Exists(chunkFilePath)){_logger.LogDebug("分片 {Index} 已存在,跳过写入", request.Index);return;}try{await using var stream = new FileStream(chunkFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024);await request.File.CopyToAsync(stream);_logger.LogInformation("分片文件==={chunkFilePath}上传成功", chunkFilePath);}catch (Exception ex){_logger.LogWarning(ex, "分片上传失败");throw new BusinessException(500, "分片上传失败,稍后重试");}}public async Task MergeChunksAsync(ChunkUploadMergeRequest request){var chunkPath = Path.Combine(Directory.GetCurrentDirectory(), _chunkDir, $"{request.FileMD5}_{request.UploadId}");if (!Directory.Exists(chunkPath)){throw new BusinessException(500, "分片目录不存在");}var finalDir = Path.Combine(Directory.GetCurrentDirectory(), _finalDir);if (!Directory.Exists(finalDir)){Directory.CreateDirectory(finalDir);}var fileName = request.FileName;var finalFilePath = Path.Combine(finalDir, fileName);if (File.Exists(finalFilePath)){_logger.LogInformation("文件已存在==={finalFilePath}", finalFilePath);// 避免覆盖var timeSuffix = DateTime.Now.ToString("yyyyMMddHHmmssfff");var fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileName);var extension = Path.GetExtension(fileName);// 随机数防止并发冲突var rand = new Random().Next(1000, 9999);var uuid = Guid.NewGuid().ToString("D");finalFilePath = Path.Combine(finalDir, $"{fileNameWithoutExt}_{timeSuffix}_{uuid}_{rand}{extension}");}try{var chunkFiles = Directory.GetFiles(chunkPath).OrderBy(f => int.Parse(Path.GetFileName(f).Split('_')[1].Replace(".tmp", ""))).ToList();using var md5 = MD5.Create();await using var finalStream = new FileStream(finalFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024);await using var cryptoStream = new CryptoStream(Stream.Null, md5, CryptoStreamMode.Write);var buffer = new byte[1024 * 1024];foreach (var chunkFile in chunkFiles){await using var chunkStream = new FileStream(chunkFile, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024);int bytesRead;while ((bytesRead = await chunkStream.ReadAsync(buffer)) > 0){await finalStream.WriteAsync(buffer.AsMemory(0, bytesRead));await cryptoStream.WriteAsync(buffer.AsMemory(0, bytesRead));}}await cryptoStream.FlushFinalBlockAsync();var mergedMD5 = BitConverter.ToString(md5.Hash!).Replace("-", "").ToLowerInvariant();if (!string.Equals(mergedMD5, request.FileMD5, StringComparison.OrdinalIgnoreCase)){File.Delete(finalFilePath);throw new BusinessException(500, "分片合并文件MD5校验失败");}_logger.LogInformation("分片合并成成功:{ChunkPath}", chunkPath);// 清理分片目录_ = Task.Run(async () =>{var timeSpan = TimeSpan.FromMinutes(1);try{await Task.Delay(timeSpan);Directory.Delete(chunkPath, true);_logger.LogInformation("延迟==={@timeSpan}===删除分片目录成功:{ChunkPath}", timeSpan, chunkPath);}catch (Exception ex){_logger.LogWarning(ex, "延迟==={@timeSpan}===删除分片目录异常:{ChunkPath}", timeSpan, chunkPath);}});}catch (Exception ex){_logger.LogWarning(ex, "分片合并失败");throw new BusinessException(500, "分片合并失败,稍后重试");}}
}
参数定义
分片初始化参数
using System.ComponentModel.DataAnnotations;namespace dotnet_start.Model.Request;/// <summary>
/// 分片上传
/// </summary>
public class ChunkUploadInitRequest
{/// <summary>/// 文件MD5/// </summary>[Required(ErrorMessage = "文件 MD5 不能为空")][RegularExpression("^[a-fA-F0-9]{32}$", ErrorMessage = "文件MD5格式不正确")][StringLength(32, MinimumLength = 32, ErrorMessage = "文件 MD5 必须是32位")]public required string FileMD5 { get; set; }/// <summary>/// 文件大小/// </summary>[Required(ErrorMessage = "文件大小不能为空")][Range(1, long.MaxValue, ErrorMessage = "文件大小必须大于0")]public int FileSize { get; set; }
}
分片上传分片参数
using System.ComponentModel.DataAnnotations;namespace dotnet_start.Model.Request;/// <summary>
/// 分片上传开始参数
/// </summary>
public class ChunkUploadStartRequest
{/// <summary>/// 上传文件/// </summary>[Required(ErrorMessage = "上传文件不能为空")]public required IFormFile File { get; set; }/// <summary>/// 上传唯一ID/// </summary>[Required(ErrorMessage = "上传唯一ID不能为空")]public required string UploadId { get; set; }/// <summary>/// 文件MD5/// </summary>[Required(ErrorMessage = "文件MD5不能为空")][StringLength(32, MinimumLength = 32, ErrorMessage = "文件MD5必须是32位")][RegularExpression("^[a-fA-F0-9]{32}$", ErrorMessage = "文件MD5格式不正确")]public required string FileMD5 { get; set; }/// <summary>/// 分片索引/// </summary>[Required(ErrorMessage = "分片索引不能为空")][Range(0, 2000, ErrorMessage = "分片索引最多2000个")]public required int Index { get; set; }
}
分片上传合并参数
using System.ComponentModel.DataAnnotations;namespace dotnet_start.Model.Request;/// <summary>
/// 分片上传合并参数
/// </summary>
public class ChunkUploadMergeRequest
{/// <summary>/// 文件名称/// </summary>[Required(ErrorMessage = "文件名称不能为空")]public required string FileName { get; set; }/// <summary>/// 上传唯一ID/// </summary>[Required(ErrorMessage = "上传唯一ID不能为空")]public required string UploadId { get; set; }/// <summary>/// 文件MD5/// </summary>[Required(ErrorMessage = "文件 MD5 不能为空")][RegularExpression("^[a-fA-F0-9]{32}$", ErrorMessage = "文件MD5格式不正确")][StringLength(32, MinimumLength = 32, ErrorMessage = "文件 MD5 必须是32位")]public required string FileMD5 { get; set; }}
请求控制器
using dotnet_start.Model.Request;
using dotnet_start.Model.Response;
using dotnet_start.Services;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;namespace dotnet_start.Controllers;/// <summary>
/// 文件分片上传控制器
/// </summary>
/// <param name="service">文件分片上传服务</param>
[SwaggerTag("分片上传请求控制器")]
[ApiController]
[Route("chunk")]
public class ChunkUploadController(ChunkUploadService service) : ControllerBase
{/// <summary>/// 分片上传初始化/// </summary>/// <returns>CommonResult</returns>[HttpPost("upload/init")][ProducesResponseType(typeof(CommonResult<ChunkUploadInitResponse>), StatusCodes.Status200OK)]public IActionResult PostUploadChunkInit([FromForm] ChunkUploadInitRequest request){service._logger.LogDebug("分片上传的初始化参数==={@request}", request);return Ok(CommonResult<ChunkUploadInitResponse>.Success("上传初始化成功", service.InitUpload(request)));}/// <summary>/// 分片上传开始/// </summary>/// <returns>CommonResult</returns>[HttpPost("upload/start")][ProducesResponseType(typeof(CommonResult<string>), StatusCodes.Status200OK)]public async Task<IActionResult> PostUploadChunkStart([FromForm] ChunkUploadStartRequest request){service._logger.LogDebug("分片上传参数==={@request}", request);await service.UploadChunkAsync(request);return Ok(CommonResult<string>.Success(200, "分片上传成功"));}/// <summary>/// 分片上传合并/// </summary>/// <returns>CommonResult</returns>[HttpPost("upload/merge")][ProducesResponseType(typeof(CommonResult<string>), StatusCodes.Status200OK)]public async Task<IActionResult> PostUploadChunkMerge([FromBody] ChunkUploadMergeRequest request){service._logger.LogDebug("分片合并参数==={@request}", request);await service.MergeChunksAsync(request);return Ok(CommonResult<string>.Success(200, "分片合并并校验成功"));}}
2.前端调用
浏览器F12
后台控制台日志
查看上传文件目录
到此为止,asp.net core后端处理文件分片上传已完成。至于前端,使用原声带js+html,vue或者react等都可以,只要匹配后端参数即可。欢迎留言点赞与评论。