夜鹰 преди 4 месеца
родител
ревизия
ef4e31df1d
променени са 2 файла, в които са добавени 446 реда и са изтрити 434 реда
  1. 443 432
      Admin.NET/Admin.NET.Core/Service/File/SysFileService.cs
  2. 3 2
      Admin.NET/Admin.NET.Core/Utils/CommonUtil.cs

+ 443 - 432
Admin.NET/Admin.NET.Core/Service/File/SysFileService.cs

@@ -1,433 +1,444 @@
-// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
-//
-// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
-//
-// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
-
-using Aliyun.OSS.Util;
-using Furion.AspNetCore;
-using Microsoft.AspNetCore.Mvc.ModelBinding;
-
-namespace Admin.NET.Core.Service;
-
-/// <summary>
-/// 系统文件服务 🧩
-/// </summary>
-[ApiDescriptionSettings(Order = 410, Description = "系统文件")]
-public class SysFileService : IDynamicApiController, ITransient
-{
-    private readonly UserManager _userManager;
-    private readonly SqlSugarRepository<SysFile> _sysFileRep;
-    private readonly OSSProviderOptions _OSSProviderOptions;
-    private readonly UploadOptions _uploadOptions;
-    private readonly IConfiguration _configuration;
-    private readonly string _imageType = ".jpeg.jpg.png.bmp.gif.tif";
-    private readonly INamedServiceProvider<ICustomFileProvider> _namedServiceProvider;
-    private readonly ICustomFileProvider _customFileProvider;
-
-    public SysFileService(UserManager userManager,
-        SqlSugarRepository<SysFile> sysFileRep,
-        IOptions<OSSProviderOptions> oSSProviderOptions,
-        IOptions<UploadOptions> uploadOptions,
-        INamedServiceProvider<ICustomFileProvider> namedServiceProvider,
-        IConfiguration configuration)
-    {
-        _namedServiceProvider = namedServiceProvider;
-        _userManager = userManager;
-        _sysFileRep = sysFileRep;
-        _OSSProviderOptions = oSSProviderOptions.Value;
-        _uploadOptions = uploadOptions.Value;
-        _configuration = configuration;
-
-        // 简化提供者选择逻辑
-        if (_OSSProviderOptions.Enabled || _configuration["MultiOSS:Enabled"].ToBoolean())
-        {
-            // 统一使用MultiOSSFileProvider处理所有OSS情况
-            _customFileProvider = _namedServiceProvider.GetService<ITransient>(nameof(MultiOSSFileProvider));
-        }
-        else if (_configuration["SSHProvider:Enabled"].ToBoolean())
-        {
-            _customFileProvider = _namedServiceProvider.GetService<ITransient>(nameof(SSHFileProvider));
-        }
-        else
-        {
-            _customFileProvider = _namedServiceProvider.GetService<ITransient>(nameof(DefaultFileProvider));
-        }
-    }
-
-    /// <summary>
-    /// 获取文件分页列表 🔖
-    /// </summary>
-    /// <param name="input"></param>
-    /// <returns></returns>
-    [DisplayName("获取文件分页列表")]
-    public async Task<SqlSugarPagedList<SysFile>> Page(PageFileInput input)
-    {
-        // 获取所有公开附件
-        var publicList = _sysFileRep.AsQueryable().ClearFilter().Where(u => u.IsPublic == true);
-        // 获取私有附件
-        var privateList = _sysFileRep.AsQueryable().Where(u => u.IsPublic == false);
-        // 合并公开和私有附件并分页
-        return await _sysFileRep.Context.UnionAll(publicList, privateList)
-            .WhereIF(!string.IsNullOrWhiteSpace(input.FileName), u => u.FileName.Contains(input.FileName.Trim()))
-            .WhereIF(!string.IsNullOrWhiteSpace(input.FilePath), u => u.FilePath.Contains(input.FilePath.Trim()))
-            .WhereIF(!string.IsNullOrWhiteSpace(input.StartTime.ToString()) && !string.IsNullOrWhiteSpace(input.EndTime.ToString()),
-                u => u.CreateTime >= input.StartTime && u.CreateTime <= input.EndTime)
-            .OrderBy(u => u.CreateTime, OrderByType.Desc)
-            .ToPagedListAsync(input.Page, input.PageSize);
-    }
-
-    /// <summary>
-    /// 上传文件Base64 🔖
-    /// </summary>
-    /// <param name="input"></param>
-    /// <returns></returns>
-    [DisplayName("上传文件Base64")]
-    public async Task<SysFile> UploadFileFromBase64(UploadFileFromBase64Input input)
-    {
-        var pattern = @"data:(?<type>.+?);base64,(?<data>[^""]+)";
-        var regex = new Regex(pattern, RegexOptions.Compiled);
-        var match = regex.Match(input.FileDataBase64);
-
-        byte[] fileData = Convert.FromBase64String(match.Groups["data"].Value);
-        var contentType = match.Groups["type"].Value;
-        if (string.IsNullOrEmpty(input.FileName))
-            input.FileName = $"{YitIdHelper.NextId()}.{contentType.AsSpan(contentType.LastIndexOf('/') + 1)}";
-
-        using var ms = new MemoryStream();
-        ms.Write(fileData);
-        ms.Seek(0, SeekOrigin.Begin);
-        IFormFile formFile = new FormFile(ms, 0, fileData.Length, "file", input.FileName)
-        {
-            Headers = new HeaderDictionary(),
-            ContentType = contentType
-        };
-        var uploadFileInput = input.Adapt<UploadFileInput>();
-        uploadFileInput.File = formFile;
-        return await UploadFile(uploadFileInput);
-    }
-
-    /// <summary>
-    /// 上传多文件 🔖
-    /// </summary>
-    /// <param name="files"></param>
-    /// <returns></returns>
-    [DisplayName("上传多文件")]
-    public async Task<List<SysFile>> UploadFiles([Required] List<IFormFile> files)
-    {
-        var fileList = new List<SysFile>();
-        foreach (var file in files)
-        {
-            var uploadedFile = await UploadFile(new UploadFileInput { File = file });
-            fileList.Add(uploadedFile);
-        }
-        return fileList;
-    }
-
-    /// <summary>
-    /// 根据文件Id或Url下载 🔖
-    /// </summary>
-    /// <param name="input"></param>
-    /// <returns></returns>
-    [DisplayName("根据文件Id或Url下载")]
-    public async Task<IActionResult> DownloadFile(SysFile input)
-    {
-        var file = input.Id > 0 ? await GetFile(input.Id) : await _sysFileRep.CopyNew().GetFirstAsync(u => u.Url == input.Url);
-        var fileName = HttpUtility.UrlEncode(file.FileName, Encoding.GetEncoding("UTF-8"));
-        return await GetFileStreamResult(file, fileName);
-    }
-
-    /// <summary>
-    /// 文件预览 🔖
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    [DisplayName("文件预览")]
-    public async Task<IActionResult> GetPreview([FromRoute] long id)
-    {
-        var file = await GetFile(id);
-        //var fileName = HttpUtility.UrlEncode(file.FileName, Encoding.GetEncoding("UTF-8"));
-        return await GetFileStreamResult(file, file.Id + "");
-    }
-
-    /// <summary>
-    /// 获取文件流
-    /// </summary>
-    /// <param name="file"></param>
-    /// <param name="fileName"></param>
-    /// <returns></returns>
-    private async Task<IActionResult> GetFileStreamResult(SysFile file, string fileName)
-    {
-        return await _customFileProvider.GetFileStreamResultAsync(file, fileName);
-    }
-
-    /// <summary>
-    /// 下载指定文件Base64格式 🔖
-    /// </summary>
-    /// <param name="url"></param>
-    /// <returns></returns>
-    [DisplayName("下载指定文件Base64格式")]
-    public async Task<string> DownloadFileBase64([FromBody] string url)
-    {
-        var sysFile = await _sysFileRep.CopyNew().GetFirstAsync(u => u.Url == url) ?? throw Oops.Oh($"文件不存在");
-        return await _customFileProvider.DownloadFileBase64Async(sysFile);
-    }
-
-    /// <summary>
-    /// 删除文件 🔖
-    /// </summary>
-    /// <param name="input"></param>
-    /// <returns></returns>
-    [ApiDescriptionSettings(Name = "Delete"), HttpPost]
-    [DisplayName("删除文件")]
-    public async Task DeleteFile(BaseIdInput input)
-    {
-        var file = await _sysFileRep.GetByIdAsync(input.Id) ?? throw Oops.Oh($"文件不存在");
-        await _sysFileRep.DeleteAsync(file);
-        await _customFileProvider.DeleteFileAsync(file);
-    }
-
-    /// <summary>
-    /// 更新文件 🔖
-    /// </summary>
-    /// <param name="input"></param>
-    /// <returns></returns>
-    [ApiDescriptionSettings(Name = "Update"), HttpPost]
-    [DisplayName("更新文件")]
-    public async Task UpdateFile(SysFile input)
-    {
-        var isExist = await _sysFileRep.IsAnyAsync(u => u.Id == input.Id);
-        if (!isExist) throw Oops.Oh(ErrorCodeEnum.D8000);
-
-        await _sysFileRep.UpdateAsync(input);
-    }
-
-    /// <summary>
-    /// 获取文件 🔖
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    [DisplayName("获取文件")]
-    public async Task<SysFile> GetFile([FromQuery] long id)
-    {
-        var file = await _sysFileRep.CopyNew().GetByIdAsync(id);
-        return file ?? throw Oops.Oh(ErrorCodeEnum.D8000);
-    }
-
-    /// <summary>
-    /// 根据文件Id集合获取文件 🔖
-    /// </summary>
-    /// <param name="ids"></param>
-    /// <returns></returns>
-    [DisplayName("根据文件Id集合获取文件")]
-    public async Task<List<SysFile>> GetFileByIds([FromQuery][FlexibleArray<long>] List<long> ids)
-    {
-        return await _sysFileRep.AsQueryable().Where(u => ids.Contains(u.Id)).ToListAsync();
-    }
-
-    /// <summary>
-    /// 获取文件路径 🔖
-    /// </summary>
-    /// <returns></returns>
-    [DisplayName("获取文件路径")]
-    public async Task<List<TreeNode>> GetFolder()
-    {
-        // 优化:直接在数据库层面获取不重复的文件路径
-        var folders = await _sysFileRep.AsQueryable()
-            .Select(u => u.FilePath)
-            .Distinct()
-            .ToListAsync();
-
-        var pathTreeBuilder = new PathTreeBuilder();
-        var tree = pathTreeBuilder.BuildTree(folders);
-        return tree.Children;
-    }
-
-    /// <summary>
-    /// 上传文件 🔖
-    /// </summary>
-    /// <param name="input"></param>
-    /// <param name="targetPath">存储目标路径</param>
-    /// <returns></returns>
-    [DisplayName("上传文件")]
-    public async Task<SysFile> UploadFile([FromForm] UploadFileInput input, [BindNever] string targetPath = "")
-    {
-        if (input.File == null || input.File.Length <= 0) throw Oops.Oh(ErrorCodeEnum.D8000);
-
-        if (input.File.FileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) throw Oops.Oh(ErrorCodeEnum.D8005);
-
-        // 判断是否重复上传的文件
-        var sizeKb = input.File.Length / 1024; // 大小KB
-        var fileMd5 = string.Empty;
-        if (_uploadOptions.EnableMd5)
-        {
-            await using (var fileStream = input.File.OpenReadStream())
-            {
-                fileMd5 = OssUtils.ComputeContentMd5(fileStream, fileStream.Length);
-            }
-            // Mysql8 中如果使用了 utf8mb4_general_ci 之外的编码会出错,尽量避免在条件里使用.ToString()
-            // 因为 Squsugar 并不是把变量转换为字符串来构造SQL语句,而是构造了CAST(123 AS CHAR)这样的语句,这样这个返回值是utf8mb4_general_ci,所以容易出错。
-            var sysFile = await _sysFileRep.GetFirstAsync(u => u.FileMd5 == fileMd5 && u.SizeKb == sizeKb);
-            if (sysFile != null) return sysFile;
-        }
-
-        // 验证文件类型
-        if (!_uploadOptions.ContentType.Contains(input.File.ContentType)) throw Oops.Oh($"{ErrorCodeEnum.D8001}:{input.File.ContentType}");
-
-        // 验证文件大小
-        if (sizeKb > _uploadOptions.MaxSize) throw Oops.Oh($"{ErrorCodeEnum.D8002},允许最大:{_uploadOptions.MaxSize}KB");
-
-        // 获取文件后缀
-        var suffix = Path.GetExtension(input.File.FileName).ToLower(); // 后缀
-        if (string.IsNullOrWhiteSpace(suffix))
-            suffix = string.Concat(".", input.File.ContentType.AsSpan(input.File.ContentType.LastIndexOf('/') + 1));
-        if (!string.IsNullOrWhiteSpace(suffix))
-        {
-            //var contentTypeProvider = FS.GetFileExtensionContentTypeProvider();
-            //suffix = contentTypeProvider.Mappings.FirstOrDefault(u => u.Value == file.ContentType).Key;
-            // 修改 image/jpeg 类型返回的 .jpeg、jpe 后缀
-            if (suffix == ".jpeg" || suffix == ".jpe")
-                suffix = ".jpg";
-        }
-        if (string.IsNullOrWhiteSpace(suffix)) throw Oops.Oh(ErrorCodeEnum.D8003);
-
-        // 防止客户端伪造文件类型
-        if (!string.IsNullOrWhiteSpace(input.AllowSuffix) && !input.AllowSuffix.Contains(suffix)) throw Oops.Oh(ErrorCodeEnum.D8003);
-        //if (!VerifyFileExtensionName.IsSameType(file.OpenReadStream(), suffix)) throw Oops.Oh(ErrorCodeEnum.D8001);
-
-        // 文件存储位置
-        var path = string.IsNullOrWhiteSpace(targetPath) ? _uploadOptions.Path : targetPath;
-        path = path.ParseToDateTimeForRep();
-
-        var newFile = input.Adapt<SysFile>();
-        newFile.Id = YitIdHelper.NextId();
-
-        // 优先使用用户指定的存储桶名称,如果没有指定则使用默认配置
-        if (!string.IsNullOrEmpty(input.BucketName))
-        {
-            newFile.BucketName = input.BucketName;
-        }
-        else
-        {
-            // MultiOSSFileProvider会自动使用默认配置
-            newFile.BucketName = _OSSProviderOptions.Enabled ? _OSSProviderOptions.Bucket : "Local";
-        }
-
-        newFile.FileName = Path.GetFileNameWithoutExtension(input.File.FileName);
-        newFile.Suffix = suffix;
-        newFile.SizeKb = sizeKb;
-        newFile.FilePath = path;
-        newFile.FileMd5 = fileMd5;
-
-        var finalName = newFile.Id + suffix; // 文件最终名称
-
-        newFile = await _customFileProvider.UploadFileAsync(input.File, newFile, path, finalName);
-        await _sysFileRep.AsInsertable(newFile).ExecuteCommandAsync();
-        return newFile;
-    }
-
-    /// <summary>
-    /// 上传头像 🔖
-    /// </summary>
-    /// <param name="file"></param>
-    /// <returns></returns>
-    [DisplayName("上传头像")]
-    public async Task<SysFile> UploadAvatar([Required] IFormFile file)
-    {
-        var sysFile = await UploadFile(new UploadFileInput { File = file, AllowSuffix = _imageType }, "upload/avatar");
-
-        var sysUserRep = _sysFileRep.ChangeRepository<SqlSugarRepository<SysUser>>();
-        var user = await sysUserRep.GetByIdAsync(_userManager.UserId);
-        await sysUserRep.UpdateAsync(u => new SysUser() { Avatar = sysFile.Url }, u => u.Id == user.Id);
-        // 删除已有头像文件
-        if (!string.IsNullOrWhiteSpace(user.Avatar))
-        {
-            var fileId = Path.GetFileNameWithoutExtension(user.Avatar);
-            if (long.TryParse(fileId, out var id))
-            {
-                try
-                {
-                    await DeleteFile(new BaseIdInput { Id = id });
-                }
-                catch
-                {
-                    // 忽略删除旧头像文件的错误,不影响新头像上传
-                }
-            }
-        }
-
-        return sysFile;
-    }
-
-    /// <summary>
-    /// 上传电子签名 🔖
-    /// </summary>
-    /// <param name="file"></param>
-    /// <returns></returns>
-    [DisplayName("上传电子签名")]
-    public async Task<SysFile> UploadSignature([Required] IFormFile file)
-    {
-        var sysFile = await UploadFile(new UploadFileInput { File = file, AllowSuffix = _imageType }, "upload/signature");
-
-        var sysUserRep = _sysFileRep.ChangeRepository<SqlSugarRepository<SysUser>>();
-        var user = await sysUserRep.GetByIdAsync(_userManager.UserId);
-        // 删除已有电子签名文件
-        if (!string.IsNullOrWhiteSpace(user.Signature) && user.Signature.EndsWith(".png"))
-        {
-            var fileId = Path.GetFileNameWithoutExtension(user.Signature);
-            if (long.TryParse(fileId, out var id))
-            {
-                try
-                {
-                    await DeleteFile(new BaseIdInput { Id = id });
-                }
-                catch
-                {
-                    // 忽略删除旧签名文件的错误,不影响新签名上传
-                }
-            }
-        }
-        await sysUserRep.UpdateAsync(u => new SysUser() { Signature = sysFile.Url }, u => u.Id == user.Id);
-        return sysFile;
-    }
-
-    #region 统一实体与文件关联时,业务应用实体只需要定义一个SysFile集合导航属性,业务增加和更新、删除分别调用即可
-
-    /// <summary>
-    /// 更新文件的业务数据Id
-    /// </summary>
-    /// <param name="dataId"></param>
-    /// <param name="sysFiles"></param>
-    /// <returns></returns>
-    [NonAction]
-    public async Task UpdateFileByDataId(long dataId, List<SysFile> sysFiles)
-    {
-        var newFileIds = sysFiles.Select(u => u.Id).ToList();
-
-        // 求文件Id差集并删除(无效文件)
-        var tmpFiles = await _sysFileRep.GetListAsync(u => u.DataId == dataId);
-        var tmpFileIds = tmpFiles.Select(u => u.Id).ToList();
-        var deleteFileIds = tmpFileIds.Except(newFileIds);
-        foreach (var fileId in deleteFileIds)
-            await DeleteFile(new BaseIdInput() { Id = fileId });
-
-        await _sysFileRep.UpdateAsync(u => new SysFile() { DataId = dataId }, u => newFileIds.Contains(u.Id));
-    }
-
-    /// <summary>
-    /// 删除业务数据对应的文件
-    /// </summary>
-    /// <param name="dataId"></param>
-    /// <returns></returns>
-    [NonAction]
-    public async Task DeleteFileByDataId(long dataId)
-    {
-        // 删除冗余无效的物理文件
-        var tmpFiles = await _sysFileRep.GetListAsync(u => u.DataId == dataId);
-        foreach (var file in tmpFiles)
-            await _customFileProvider.DeleteFileAsync(file);
-        await _sysFileRep.AsDeleteable().Where(u => u.DataId == dataId).ExecuteCommandAsync();
-    }
-
-    #endregion 统一实体与文件关联时,业务应用实体只需要定义一个SysFile集合导航属性,业务增加和更新、删除分别调用即可
+// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
+//
+// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
+//
+// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
+
+using Aliyun.OSS.Util;
+using Furion.AspNetCore;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Admin.NET.Core.Service;
+
+/// <summary>
+/// 系统文件服务 🧩
+/// </summary>
+[ApiDescriptionSettings(Order = 410, Description = "系统文件")]
+public class SysFileService : IDynamicApiController, ITransient
+{
+    private readonly UserManager _userManager;
+    private readonly SqlSugarRepository<SysFile> _sysFileRep;
+    private readonly OSSProviderOptions _OSSProviderOptions;
+    private readonly UploadOptions _uploadOptions;
+    private readonly IConfiguration _configuration;
+    private readonly string _imageType = ".jpeg.jpg.png.bmp.gif.tif";
+    private readonly INamedServiceProvider<ICustomFileProvider> _namedServiceProvider;
+    private readonly ICustomFileProvider _customFileProvider;
+
+    public SysFileService(UserManager userManager,
+        SqlSugarRepository<SysFile> sysFileRep,
+        IOptions<OSSProviderOptions> oSSProviderOptions,
+        IOptions<UploadOptions> uploadOptions,
+        INamedServiceProvider<ICustomFileProvider> namedServiceProvider,
+        IConfiguration configuration)
+    {
+        _namedServiceProvider = namedServiceProvider;
+        _userManager = userManager;
+        _sysFileRep = sysFileRep;
+        _OSSProviderOptions = oSSProviderOptions.Value;
+        _uploadOptions = uploadOptions.Value;
+        _configuration = configuration;
+
+        // 简化提供者选择逻辑
+        if (_OSSProviderOptions.Enabled || _configuration["MultiOSS:Enabled"].ToBoolean())
+        {
+            // 统一使用MultiOSSFileProvider处理所有OSS情况
+            _customFileProvider = _namedServiceProvider.GetService<ITransient>(nameof(MultiOSSFileProvider));
+        }
+        else if (_configuration["SSHProvider:Enabled"].ToBoolean())
+        {
+            _customFileProvider = _namedServiceProvider.GetService<ITransient>(nameof(SSHFileProvider));
+        }
+        else
+        {
+            _customFileProvider = _namedServiceProvider.GetService<ITransient>(nameof(DefaultFileProvider));
+        }
+    }
+
+    /// <summary>
+    /// 获取文件分页列表 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [DisplayName("获取文件分页列表")]
+    public async Task<SqlSugarPagedList<SysFile>> Page(PageFileInput input)
+    {
+        // 获取所有公开附件
+        var publicList = _sysFileRep.AsQueryable().ClearFilter().Where(u => u.IsPublic == true);
+        // 获取私有附件
+        var privateList = _sysFileRep.AsQueryable().Where(u => u.IsPublic == false);
+        // 合并公开和私有附件并分页
+        return await _sysFileRep.Context.UnionAll(publicList, privateList)
+            .WhereIF(!string.IsNullOrWhiteSpace(input.FileName), u => u.FileName.Contains(input.FileName.Trim()))
+            .WhereIF(!string.IsNullOrWhiteSpace(input.FilePath), u => u.FilePath.Contains(input.FilePath.Trim()))
+            .WhereIF(!string.IsNullOrWhiteSpace(input.StartTime.ToString()) && !string.IsNullOrWhiteSpace(input.EndTime.ToString()),
+                u => u.CreateTime >= input.StartTime && u.CreateTime <= input.EndTime)
+            .OrderBy(u => u.CreateTime, OrderByType.Desc)
+            .ToPagedListAsync(input.Page, input.PageSize);
+    }
+
+    /// <summary>
+    /// 上传文件Base64 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [DisplayName("上传文件Base64")]
+    public async Task<SysFile> UploadFileFromBase64(UploadFileFromBase64Input input)
+    {
+        var pattern = @"data:(?<type>.+?);base64,(?<data>[^""]+)";
+        var regex = new Regex(pattern, RegexOptions.Compiled);
+        var match = regex.Match(input.FileDataBase64);
+
+        byte[] fileData = Convert.FromBase64String(match.Groups["data"].Value);
+        var contentType = match.Groups["type"].Value;
+        if (string.IsNullOrEmpty(input.FileName))
+            input.FileName = $"{YitIdHelper.NextId()}.{contentType.AsSpan(contentType.LastIndexOf('/') + 1)}";
+
+        using var ms = new MemoryStream();
+        ms.Write(fileData);
+        ms.Seek(0, SeekOrigin.Begin);
+        IFormFile formFile = new FormFile(ms, 0, fileData.Length, "file", input.FileName)
+        {
+            Headers = new HeaderDictionary(),
+            ContentType = contentType
+        };
+        var uploadFileInput = input.Adapt<UploadFileInput>();
+        uploadFileInput.File = formFile;
+        return await UploadFile(uploadFileInput);
+    }
+
+    /// <summary>
+    /// 上传多文件 🔖
+    /// </summary>
+    /// <param name="files"></param>
+    /// <returns></returns>
+    [DisplayName("上传多文件")]
+    public async Task<List<SysFile>> UploadFiles([Required] List<IFormFile> files)
+    {
+        var fileList = new List<SysFile>();
+        foreach (var file in files)
+        {
+            var uploadedFile = await UploadFile(new UploadFileInput { File = file });
+            fileList.Add(uploadedFile);
+        }
+        return fileList;
+    }
+
+    /// <summary>
+    /// 根据文件Id或Url下载 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [DisplayName("根据文件Id或Url下载")]
+    public async Task<IActionResult> DownloadFile(SysFile input)
+    {
+        var file = input.Id > 0 ? await GetFile(input.Id) : await _sysFileRep.CopyNew().GetFirstAsync(u => u.Url == input.Url);
+        var fileName = HttpUtility.UrlEncode(file.FileName, Encoding.GetEncoding("UTF-8"));
+        return await GetFileStreamResult(file, fileName);
+    }
+
+    /// <summary>
+    /// 文件预览 🔖
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    [DisplayName("文件预览")]
+    public async Task<IActionResult> GetPreview([FromRoute] long id)
+    {
+        var file = await GetFile(id);
+        //var fileName = HttpUtility.UrlEncode(file.FileName, Encoding.GetEncoding("UTF-8"));
+        return await GetFileStreamResult(file, file.Id + "");
+    }
+
+    /// <summary>
+    /// 获取文件流
+    /// </summary>
+    /// <param name="file"></param>
+    /// <param name="fileName"></param>
+    /// <returns></returns>
+    private async Task<IActionResult> GetFileStreamResult(SysFile file, string fileName)
+    {
+        return await _customFileProvider.GetFileStreamResultAsync(file, fileName);
+    }
+
+    /// <summary>
+    /// 获取文件流
+    /// </summary>
+    [NonAction]
+    public async Task<Stream> GetFileStream(SysFile file)
+    {
+        var fileName = HttpUtility.UrlEncode(file.FileName, Encoding.GetEncoding("UTF-8"));
+        var result = await _customFileProvider.GetFileStreamResultAsync(file, fileName);
+        return result.FileStream;
+    }
+
+    /// <summary>
+    /// 下载指定文件Base64格式 🔖
+    /// </summary>
+    /// <param name="url"></param>
+    /// <returns></returns>
+    [DisplayName("下载指定文件Base64格式")]
+    public async Task<string> DownloadFileBase64([FromBody] string url)
+    {
+        var sysFile = await _sysFileRep.CopyNew().GetFirstAsync(u => u.Url == url) ?? throw Oops.Oh($"文件不存在");
+        return await _customFileProvider.DownloadFileBase64Async(sysFile);
+    }
+
+    /// <summary>
+    /// 删除文件 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [ApiDescriptionSettings(Name = "Delete"), HttpPost]
+    [DisplayName("删除文件")]
+    public async Task DeleteFile(BaseIdInput input)
+    {
+        var file = await _sysFileRep.GetByIdAsync(input.Id) ?? throw Oops.Oh($"文件不存在");
+        await _sysFileRep.DeleteAsync(file);
+        await _customFileProvider.DeleteFileAsync(file);
+    }
+
+    /// <summary>
+    /// 更新文件 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [ApiDescriptionSettings(Name = "Update"), HttpPost]
+    [DisplayName("更新文件")]
+    public async Task UpdateFile(SysFile input)
+    {
+        var isExist = await _sysFileRep.IsAnyAsync(u => u.Id == input.Id);
+        if (!isExist) throw Oops.Oh(ErrorCodeEnum.D8000);
+
+        await _sysFileRep.UpdateAsync(input);
+    }
+
+    /// <summary>
+    /// 获取文件 🔖
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    [DisplayName("获取文件")]
+    public async Task<SysFile> GetFile([FromQuery] long id)
+    {
+        var file = await _sysFileRep.CopyNew().GetByIdAsync(id);
+        return file ?? throw Oops.Oh(ErrorCodeEnum.D8000);
+    }
+
+    /// <summary>
+    /// 根据文件Id集合获取文件 🔖
+    /// </summary>
+    /// <param name="ids"></param>
+    /// <returns></returns>
+    [DisplayName("根据文件Id集合获取文件")]
+    public async Task<List<SysFile>> GetFileByIds([FromQuery][FlexibleArray<long>] List<long> ids)
+    {
+        return await _sysFileRep.AsQueryable().Where(u => ids.Contains(u.Id)).ToListAsync();
+    }
+
+    /// <summary>
+    /// 获取文件路径 🔖
+    /// </summary>
+    /// <returns></returns>
+    [DisplayName("获取文件路径")]
+    public async Task<List<TreeNode>> GetFolder()
+    {
+        // 优化:直接在数据库层面获取不重复的文件路径
+        var folders = await _sysFileRep.AsQueryable()
+            .Select(u => u.FilePath)
+            .Distinct()
+            .ToListAsync();
+
+        var pathTreeBuilder = new PathTreeBuilder();
+        var tree = pathTreeBuilder.BuildTree(folders);
+        return tree.Children;
+    }
+
+    /// <summary>
+    /// 上传文件 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <param name="targetPath">存储目标路径</param>
+    /// <returns></returns>
+    [DisplayName("上传文件")]
+    public async Task<SysFile> UploadFile([FromForm] UploadFileInput input, [BindNever] string targetPath = "")
+    {
+        if (input.File == null || input.File.Length <= 0) throw Oops.Oh(ErrorCodeEnum.D8000);
+
+        if (input.File.FileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) throw Oops.Oh(ErrorCodeEnum.D8005);
+
+        // 判断是否重复上传的文件
+        var sizeKb = input.File.Length / 1024; // 大小KB
+        var fileMd5 = string.Empty;
+        if (_uploadOptions.EnableMd5)
+        {
+            await using (var fileStream = input.File.OpenReadStream())
+            {
+                fileMd5 = OssUtils.ComputeContentMd5(fileStream, fileStream.Length);
+            }
+            // Mysql8 中如果使用了 utf8mb4_general_ci 之外的编码会出错,尽量避免在条件里使用.ToString()
+            // 因为 Squsugar 并不是把变量转换为字符串来构造SQL语句,而是构造了CAST(123 AS CHAR)这样的语句,这样这个返回值是utf8mb4_general_ci,所以容易出错。
+            var sysFile = await _sysFileRep.GetFirstAsync(u => u.FileMd5 == fileMd5 && u.SizeKb == sizeKb);
+            if (sysFile != null) return sysFile;
+        }
+
+        // 验证文件类型
+        if (!_uploadOptions.ContentType.Contains(input.File.ContentType)) throw Oops.Oh($"{ErrorCodeEnum.D8001}:{input.File.ContentType}");
+
+        // 验证文件大小
+        if (sizeKb > _uploadOptions.MaxSize) throw Oops.Oh($"{ErrorCodeEnum.D8002},允许最大:{_uploadOptions.MaxSize}KB");
+
+        // 获取文件后缀
+        var suffix = Path.GetExtension(input.File.FileName).ToLower(); // 后缀
+        if (string.IsNullOrWhiteSpace(suffix))
+            suffix = string.Concat(".", input.File.ContentType.AsSpan(input.File.ContentType.LastIndexOf('/') + 1));
+        if (!string.IsNullOrWhiteSpace(suffix))
+        {
+            //var contentTypeProvider = FS.GetFileExtensionContentTypeProvider();
+            //suffix = contentTypeProvider.Mappings.FirstOrDefault(u => u.Value == file.ContentType).Key;
+            // 修改 image/jpeg 类型返回的 .jpeg、jpe 后缀
+            if (suffix == ".jpeg" || suffix == ".jpe")
+                suffix = ".jpg";
+        }
+        if (string.IsNullOrWhiteSpace(suffix)) throw Oops.Oh(ErrorCodeEnum.D8003);
+
+        // 防止客户端伪造文件类型
+        if (!string.IsNullOrWhiteSpace(input.AllowSuffix) && !input.AllowSuffix.Contains(suffix)) throw Oops.Oh(ErrorCodeEnum.D8003);
+        //if (!VerifyFileExtensionName.IsSameType(file.OpenReadStream(), suffix)) throw Oops.Oh(ErrorCodeEnum.D8001);
+
+        // 文件存储位置
+        var path = string.IsNullOrWhiteSpace(targetPath) ? _uploadOptions.Path : targetPath;
+        path = path.ParseToDateTimeForRep();
+
+        var newFile = input.Adapt<SysFile>();
+        newFile.Id = YitIdHelper.NextId();
+
+        // 优先使用用户指定的存储桶名称,如果没有指定则使用默认配置
+        if (!string.IsNullOrEmpty(input.BucketName))
+        {
+            newFile.BucketName = input.BucketName;
+        }
+        else
+        {
+            // MultiOSSFileProvider会自动使用默认配置
+            newFile.BucketName = _OSSProviderOptions.Enabled ? _OSSProviderOptions.Bucket : "Local";
+        }
+
+        newFile.FileName = Path.GetFileNameWithoutExtension(input.File.FileName);
+        newFile.Suffix = suffix;
+        newFile.SizeKb = sizeKb;
+        newFile.FilePath = path;
+        newFile.FileMd5 = fileMd5;
+
+        var finalName = newFile.Id + suffix; // 文件最终名称
+
+        newFile = await _customFileProvider.UploadFileAsync(input.File, newFile, path, finalName);
+        await _sysFileRep.AsInsertable(newFile).ExecuteCommandAsync();
+        return newFile;
+    }
+
+    /// <summary>
+    /// 上传头像 🔖
+    /// </summary>
+    /// <param name="file"></param>
+    /// <returns></returns>
+    [DisplayName("上传头像")]
+    public async Task<SysFile> UploadAvatar([Required] IFormFile file)
+    {
+        var sysFile = await UploadFile(new UploadFileInput { File = file, AllowSuffix = _imageType }, "upload/avatar");
+
+        var sysUserRep = _sysFileRep.ChangeRepository<SqlSugarRepository<SysUser>>();
+        var user = await sysUserRep.GetByIdAsync(_userManager.UserId);
+        await sysUserRep.UpdateAsync(u => new SysUser() { Avatar = sysFile.Url }, u => u.Id == user.Id);
+        // 删除已有头像文件
+        if (!string.IsNullOrWhiteSpace(user.Avatar))
+        {
+            var fileId = Path.GetFileNameWithoutExtension(user.Avatar);
+            if (long.TryParse(fileId, out var id))
+            {
+                try
+                {
+                    await DeleteFile(new BaseIdInput { Id = id });
+                }
+                catch
+                {
+                    // 忽略删除旧头像文件的错误,不影响新头像上传
+                }
+            }
+        }
+
+        return sysFile;
+    }
+
+    /// <summary>
+    /// 上传电子签名 🔖
+    /// </summary>
+    /// <param name="file"></param>
+    /// <returns></returns>
+    [DisplayName("上传电子签名")]
+    public async Task<SysFile> UploadSignature([Required] IFormFile file)
+    {
+        var sysFile = await UploadFile(new UploadFileInput { File = file, AllowSuffix = _imageType }, "upload/signature");
+
+        var sysUserRep = _sysFileRep.ChangeRepository<SqlSugarRepository<SysUser>>();
+        var user = await sysUserRep.GetByIdAsync(_userManager.UserId);
+        // 删除已有电子签名文件
+        if (!string.IsNullOrWhiteSpace(user.Signature) && user.Signature.EndsWith(".png"))
+        {
+            var fileId = Path.GetFileNameWithoutExtension(user.Signature);
+            if (long.TryParse(fileId, out var id))
+            {
+                try
+                {
+                    await DeleteFile(new BaseIdInput { Id = id });
+                }
+                catch
+                {
+                    // 忽略删除旧签名文件的错误,不影响新签名上传
+                }
+            }
+        }
+        await sysUserRep.UpdateAsync(u => new SysUser() { Signature = sysFile.Url }, u => u.Id == user.Id);
+        return sysFile;
+    }
+
+    #region 统一实体与文件关联时,业务应用实体只需要定义一个SysFile集合导航属性,业务增加和更新、删除分别调用即可
+
+    /// <summary>
+    /// 更新文件的业务数据Id
+    /// </summary>
+    /// <param name="dataId"></param>
+    /// <param name="sysFiles"></param>
+    /// <returns></returns>
+    [NonAction]
+    public async Task UpdateFileByDataId(long dataId, List<SysFile> sysFiles)
+    {
+        var newFileIds = sysFiles.Select(u => u.Id).ToList();
+
+        // 求文件Id差集并删除(无效文件)
+        var tmpFiles = await _sysFileRep.GetListAsync(u => u.DataId == dataId);
+        var tmpFileIds = tmpFiles.Select(u => u.Id).ToList();
+        var deleteFileIds = tmpFileIds.Except(newFileIds);
+        foreach (var fileId in deleteFileIds)
+            await DeleteFile(new BaseIdInput() { Id = fileId });
+
+        await _sysFileRep.UpdateAsync(u => new SysFile() { DataId = dataId }, u => newFileIds.Contains(u.Id));
+    }
+
+    /// <summary>
+    /// 删除业务数据对应的文件
+    /// </summary>
+    /// <param name="dataId"></param>
+    /// <returns></returns>
+    [NonAction]
+    public async Task DeleteFileByDataId(long dataId)
+    {
+        // 删除冗余无效的物理文件
+        var tmpFiles = await _sysFileRep.GetListAsync(u => u.DataId == dataId);
+        foreach (var file in tmpFiles)
+            await _customFileProvider.DeleteFileAsync(file);
+        await _sysFileRep.AsDeleteable().Where(u => u.DataId == dataId).ExecuteCommandAsync();
+    }
+
+    #endregion 统一实体与文件关联时,业务应用实体只需要定义一个SysFile集合导航属性,业务增加和更新、删除分别调用即可
 }

+ 3 - 2
Admin.NET/Admin.NET.Core/Utils/CommonUtil.cs

@@ -278,10 +278,11 @@ public static class CommonUtil
     public static async Task<List<T>> ImportExcelDataAsync<T>([Required] IFormFile file) where T : class, new()
     {
         var newFile = await SysFileService.UploadFile(new UploadFileInput { File = file });
-        var filePath = Path.Combine(App.WebHostEnvironment.WebRootPath, newFile.FilePath!, newFile.Id + newFile.Suffix);
+
+        await using var fileStream = await SysFileService.GetFileStream(newFile);
 
         IImporter importer = new ExcelImporter();
-        var res = await importer.Import<T>(filePath);
+        var res = await importer.Import<T>(fileStream);
 
         // 删除文件
         _ = SysFileService.DeleteFile(new BaseIdInput { Id = newFile.Id });