ASP.NET Core文件分片上传

发布于:2025-09-06 ⋅ 阅读:(13) ⋅ 点赞:(0)

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等都可以,只要匹配后端参数即可。欢迎留言点赞与评论。


网站公告

今日签到

点亮在社区的每一天
去签到