1.后端实现
服务定义
cs
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, "分片合并失败,稍后重试");
}
}
}
参数定义
分片初始化参数
cs
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; }
}
分片上传分片参数
cs
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; }
}
分片上传合并参数
cs
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; }
}
请求控制器
cs
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等都可以,只要匹配后端参数即可。欢迎留言点赞与评论。