浏览代码

feat: 增强文件存储提供者系统

✨ 新功能:
- 新增 SysFileProvider 实体,支持多存储提供者配置
- 新增 SysFileProviderService 和 SysFileProviderController,提供完整的CRUD操作
- 新增 MultiOSSFileProvider,统一处理多种OSS存储
- 新增 IOSSServiceManager,优化OSS服务管理和缓存

🔧 优化改进:
- 重构 SysFileService,支持多存储提供者选择
- 优化文件上传逻辑,支持指定存储桶
- 改进错误处理,增强系统稳定性
- 优化数据库查询性能
- 修复方法命名错误 (DeteleFileByDataId -> DeleteFileByDataId)

📝 配置更新:
- 更新 Upload.json,新增 MultiOSS 配置支持
- 更新 Startup.cs,注册新的文件提供者服务

🎯 主要特性:
- 支持多个OSS存储提供者并存
- 支持动态切换存储提供者
- 支持存储提供者配置的热更新
- 提供完整的管理界面和API
- 向后兼容现有的单一存储配置
王韩广 6 月之前
父节点
当前提交
2a79834e00

+ 10 - 0
Admin.NET/Admin.NET.Core/Service/File/Dto/FileInput.cs

@@ -62,6 +62,16 @@ public class UploadFileInput
     /// 允许格式:.jpeg.jpg.png.bmp.gif.tif
     /// </summary>
     public string AllowSuffix { get; set; }
+
+    /// <summary>
+    /// 指定存储桶名称
+    /// </summary>
+    public string? BucketName { get; set; }
+
+    /// <summary>
+    /// 指定存储提供者ID
+    /// </summary>
+    public long? ProviderId { get; set; }
 }
 
 /// <summary>

+ 182 - 0
Admin.NET/Admin.NET.Core/Service/File/Dto/FileProviderInput.cs

@@ -0,0 +1,182 @@
+// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
+//
+// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
+//
+// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
+
+namespace Admin.NET.Core.Service;
+
+/// <summary>
+/// 文件存储提供者分页查询输入参数
+/// </summary>
+public class PageFileProviderInput : BasePageInput
+{
+    /// <summary>
+    /// 存储提供者
+    /// </summary>
+    public string? Provider { get; set; }
+
+    /// <summary>
+    /// 存储桶名称
+    /// </summary>
+    public string? BucketName { get; set; }
+
+    /// <summary>
+    /// 是否启用
+    /// </summary>
+    public bool? IsEnable { get; set; }
+}
+
+/// <summary>
+/// 增加文件存储提供者输入参数
+/// </summary>
+public class AddFileProviderInput
+{
+    /// <summary>
+    /// 存储提供者
+    /// </summary>
+    [Required(ErrorMessage = "存储提供者不能为空")]
+    public string Provider { get; set; }
+
+    /// <summary>
+    /// 存储桶名称
+    /// </summary>
+    [Required(ErrorMessage = "存储桶名称不能为空")]
+    public string BucketName { get; set; }
+
+    /// <summary>
+    /// 访问密钥ID(所有云服务商统一使用此字段)
+    /// </summary>
+    public string? AccessKey { get; set; }
+
+    /// <summary>
+    /// 密钥
+    /// </summary>
+    public string? SecretKey { get; set; }
+
+    /// <summary>
+    /// 地域
+    /// </summary>
+    public string? Region { get; set; }
+
+    /// <summary>
+    /// 端点地址
+    /// </summary>
+    public string? Endpoint { get; set; }
+
+    /// <summary>
+    /// 是否启用HTTPS
+    /// </summary>
+    public bool? IsEnableHttps { get; set; } = true;
+
+    /// <summary>
+    /// 是否启用缓存
+    /// </summary>
+    public bool? IsEnableCache { get; set; } = true;
+
+    /// <summary>
+    /// 是否启用
+    /// </summary>
+    public bool? IsEnable { get; set; } = true;
+
+    /// <summary>
+    /// 是否默认提供者
+    /// </summary>
+    public bool? IsDefault { get; set; } = false;
+
+    /// <summary>
+    /// 自定义域名
+    /// </summary>
+    public string? SinceDomain { get; set; }
+
+    /// <summary>
+    /// 排序号
+    /// </summary>
+    public int? OrderNo { get; set; } = 100;
+
+    /// <summary>
+    /// 备注
+    /// </summary>
+    public string? Remark { get; set; }
+
+    /// <summary>
+    /// 支持的业务类型(JSON格式)
+    /// </summary>
+    public string? BusinessTypes { get; set; }
+
+    /// <summary>
+    /// 优先级
+    /// </summary>
+    public int Priority { get; set; } = 100;
+}
+
+/// <summary>
+/// 更新文件存储提供者输入参数
+/// </summary>
+public class UpdateFileProviderInput : AddFileProviderInput
+{
+    /// <summary>
+    /// 主键Id
+    /// </summary>
+    [Required(ErrorMessage = "主键Id不能为空")]
+    public long Id { get; set; }
+}
+
+/// <summary>
+/// 删除文件存储提供者输入参数
+/// </summary>
+public class DeleteFileProviderInput : BaseIdInput
+{
+}
+
+/// <summary>
+/// 查询文件存储提供者输入参数
+/// </summary>
+public class QueryFileProviderInput : BaseIdInput
+{
+}
+
+/// <summary>
+/// 测试连接输入参数
+/// </summary>
+public class TestConnectionInput : BaseIdInput
+{
+}
+
+/// <summary>
+/// 设置默认存储提供者输入参数
+/// </summary>
+public class SetDefaultProviderInput
+{
+    /// <summary>
+    /// 存储提供者ID
+    /// </summary>
+    [Required(ErrorMessage = "存储提供者ID不能为空")]
+    public long Id { get; set; }
+}
+
+/// <summary>
+/// 文件上传选择存储提供者输入参数
+/// </summary>
+public class SelectProviderInput
+{
+    /// <summary>
+    /// 文件类型
+    /// </summary>
+    public string? FileType { get; set; }
+
+    /// <summary>
+    /// 业务类型
+    /// </summary>
+    public string? BusinessType { get; set; }
+
+    /// <summary>
+    /// 指定提供者ID
+    /// </summary>
+    public long? ProviderId { get; set; }
+
+    /// <summary>
+    /// 指定存储桶名称
+    /// </summary>
+    public string? BucketName { get; set; }
+}

+ 46 - 18
Admin.NET/Admin.NET.Core/Service/File/FileProvider/DefaultFileProvider.cs

@@ -8,48 +8,76 @@ namespace Admin.NET.Core.Service;
 
 public class DefaultFileProvider : ICustomFileProvider, ITransient
 {
+    /// <summary>
+    /// 构建文件的完整物理路径
+    /// </summary>
+    /// <param name="sysFile"></param>
+    /// <returns></returns>
+    private string BuildFullFilePath(SysFile sysFile)
+    {
+        return Path.Combine(App.WebHostEnvironment.WebRootPath, sysFile.FilePath ?? "", $"{sysFile.Id}{sysFile.Suffix}");
+    }
+
+    /// <summary>
+    /// 构建目录的完整物理路径
+    /// </summary>
+    /// <param name="relativePath"></param>
+    /// <returns></returns>
+    private string BuildFullDirectoryPath(string relativePath)
+    {
+        return Path.Combine(App.WebHostEnvironment.WebRootPath, relativePath);
+    }
+
+    /// <summary>
+    /// 确保目录存在
+    /// </summary>
+    /// <param name="directoryPath"></param>
+    private void EnsureDirectoryExists(string directoryPath)
+    {
+        if (!Directory.Exists(directoryPath))
+            Directory.CreateDirectory(directoryPath);
+    }
+
     public Task DeleteFileAsync(SysFile sysFile)
     {
-        var filePath = Path.Combine(App.WebHostEnvironment.WebRootPath, sysFile.FilePath ?? "", sysFile.Id + sysFile.Suffix);
-        if (File.Exists(filePath)) File.Delete(filePath);
+        var filePath = BuildFullFilePath(sysFile);
+        if (File.Exists(filePath))
+            File.Delete(filePath);
         return Task.CompletedTask;
     }
 
     public async Task<string> DownloadFileBase64Async(SysFile sysFile)
     {
-        var filePath = Path.Combine(App.WebHostEnvironment.WebRootPath, sysFile.FilePath);
-        if (!Directory.Exists(filePath))
-            Directory.CreateDirectory(filePath);
-
-        var realFile = Path.Combine(filePath, $"{sysFile.Id}{sysFile.Suffix}");
+        var realFile = BuildFullFilePath(sysFile);
         if (!File.Exists(realFile))
         {
             Log.Error($"DownloadFileBase64:文件[{realFile}]不存在");
             throw Oops.Oh($"文件[{sysFile.FilePath}]不存在");
         }
+
         byte[] fileBytes = await File.ReadAllBytesAsync(realFile);
         return Convert.ToBase64String(fileBytes);
     }
 
     public Task<FileStreamResult> GetFileStreamResultAsync(SysFile sysFile, string fileName)
     {
-        var filePath = Path.Combine(sysFile.FilePath ?? "", sysFile.Id + sysFile.Suffix);
-        var path = Path.Combine(App.WebHostEnvironment.WebRootPath, filePath);
-        return Task.FromResult(new FileStreamResult(new FileStream(path, FileMode.Open), "application/octet-stream") { FileDownloadName = fileName + sysFile.Suffix });
+        var fullPath = BuildFullFilePath(sysFile);
+        return Task.FromResult(new FileStreamResult(new FileStream(fullPath, FileMode.Open), "application/octet-stream")
+        {
+            FileDownloadName = fileName + sysFile.Suffix
+        });
     }
 
     public async Task<SysFile> UploadFileAsync(IFormFile file, SysFile newFile, string path, string finalName)
     {
         newFile.Provider = ""; // 本地存储 Provider 显示为空
-        var filePath = Path.Combine(App.WebHostEnvironment.WebRootPath, path);
-        if (!Directory.Exists(filePath))
-            Directory.CreateDirectory(filePath);
 
-        var realFile = Path.Combine(filePath, finalName);
-        await using (var stream = File.Create(realFile))
-        {
-            await file.CopyToAsync(stream);
-        }
+        var directoryPath = BuildFullDirectoryPath(path);
+        EnsureDirectoryExists(directoryPath);
+
+        var realFile = Path.Combine(directoryPath, finalName);
+        await using var stream = File.Create(realFile);
+        await file.CopyToAsync(stream);
 
         newFile.Url = $"{newFile.FilePath}/{newFile.Id + newFile.Suffix}";
         return newFile;

+ 257 - 0
Admin.NET/Admin.NET.Core/Service/File/FileProvider/MultiOSSFileProvider.cs

@@ -0,0 +1,257 @@
+// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
+//
+// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
+//
+// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
+
+using OnceMi.AspNetCore.OSS;
+
+namespace Admin.NET.Core.Service;
+
+/// <summary>
+/// 多OSS文件提供者
+/// </summary>
+public class MultiOSSFileProvider : ICustomFileProvider, ITransient
+{
+    private readonly SysFileProviderService _fileProviderService;
+    private readonly IOSSServiceManager _ossServiceManager;
+    private readonly OSSProviderOptions _ossProviderOptions;
+
+    public MultiOSSFileProvider(SysFileProviderService fileProviderService,
+        IOSSServiceManager ossServiceManager,
+        IOptions<OSSProviderOptions> ossProviderOptions)
+    {
+        _fileProviderService = fileProviderService;
+        _ossServiceManager = ossServiceManager;
+        _ossProviderOptions = ossProviderOptions.Value;
+    }
+
+    /// <summary>
+    /// 上传文件
+    /// </summary>
+    /// <param name="file">文件</param>
+    /// <param name="sysFile">系统文件信息</param>
+    /// <param name="path">文件存储位置</param>
+    /// <param name="finalName">文件最终名称</param>
+    /// <returns></returns>
+    public async Task<SysFile> UploadFileAsync(IFormFile file, SysFile sysFile, string path, string finalName)
+    {
+        // 获取OSS配置(传入文件信息用于策略选择)
+        var provider = await GetFileProvider(sysFile, file) ?? throw Oops.Oh("未找到可用的存储提供者配置");
+
+        // 获取OSS服务
+        var ossService = await _ossServiceManager.GetOSSServiceAsync(provider);
+
+        // 设置文件信息
+        sysFile.Provider = provider.Provider;
+        sysFile.BucketName = provider.BucketName; // 保存原始存储桶名称
+
+        var filePath = string.Concat(path, "/", finalName);
+
+        // 上传文件
+        await ossService.PutObjectAsync(provider.BucketName, filePath, file.OpenReadStream());
+
+        // 生成外链地址
+        sysFile.Url = GenerateFileUrl(provider, provider.BucketName, filePath);
+
+        return sysFile;
+    }
+
+    /// <summary>
+    /// 删除文件
+    /// </summary>
+    /// <param name="sysFile">系统文件信息</param>
+    /// <returns></returns>
+    public async Task DeleteFileAsync(SysFile sysFile)
+    {
+        // 获取OSS配置(统一方法)
+        var provider = await GetFileProvider(sysFile) ?? throw Oops.Oh($"未找到存储提供者配置: {sysFile.Provider}-{sysFile.BucketName}");
+        var ossService = await _ossServiceManager.GetOSSServiceAsync(provider);
+        var filePath = string.Concat(sysFile.FilePath, "/", $"{sysFile.Id}{sysFile.Suffix}");
+
+        await ossService.RemoveObjectAsync(provider.BucketName, filePath);
+    }
+
+    /// <summary>
+    /// 获取文件流
+    /// </summary>
+    /// <param name="sysFile">系统文件信息</param>
+    /// <param name="fileName">文件名</param>
+    /// <returns></returns>
+    public async Task<FileStreamResult> GetFileStreamResultAsync(SysFile sysFile, string fileName)
+    {
+        // 获取OSS配置(统一方法)
+        var provider = await GetFileProvider(sysFile) ?? throw Oops.Oh($"未找到存储提供者配置: {sysFile.Provider}-{sysFile.BucketName}");
+        var ossService = await _ossServiceManager.GetOSSServiceAsync(provider);
+        var filePath = Path.Combine(sysFile.FilePath ?? "", sysFile.Id + sysFile.Suffix);
+
+        var httpRemoteService = App.GetRequiredService<IHttpRemoteService>();
+        var stream = await httpRemoteService.GetAsStreamAsync(await ossService.PresignedGetObjectAsync(provider.BucketName, filePath, 5));
+
+        return new FileStreamResult(stream, "application/octet-stream") { FileDownloadName = fileName + sysFile.Suffix };
+    }
+
+    /// <summary>
+    /// 下载文件Base64格式
+    /// </summary>
+    /// <param name="sysFile">系统文件信息</param>
+    /// <returns></returns>
+    public async Task<string> DownloadFileBase64Async(SysFile sysFile)
+    {
+        using var httpClient = new HttpClient();
+        HttpResponseMessage response = await httpClient.GetAsync(sysFile.Url);
+        if (response.IsSuccessStatusCode)
+        {
+            byte[] fileBytes = await response.Content.ReadAsByteArrayAsync();
+            return Convert.ToBase64String(fileBytes);
+        }
+        throw Oops.Oh($"下载文件失败,状态码: {response.StatusCode}");
+    }
+
+    /// <summary>
+    /// 获取文件提供者配置(统一方法,支持上传、删除、下载场景)
+    /// </summary>
+    /// <param name="sysFile">系统文件信息</param>
+    /// <param name="file">上传的文件(可选,仅上传时传入)</param>
+    /// <returns></returns>
+    private async Task<SysFileProvider?> GetFileProvider(SysFile sysFile, IFormFile? file = null)
+    {
+        // 1. 如果已指定存储桶,直接使用
+        if (!string.IsNullOrEmpty(sysFile.BucketName))
+        {
+            var provider = await _fileProviderService.GetFileProviderByBucket(sysFile.Provider, sysFile.BucketName);
+            if (provider != null) return provider;
+
+            // 如果数据库中找不到,尝试配置文件兜底
+            if (_ossProviderOptions.Enabled && _ossProviderOptions.Bucket == sysFile.BucketName)
+            {
+                return await CreateProviderFromConfiguration();
+            }
+        }
+
+        // 2. 如果有上传文件信息,使用策略选择(仅上传场景)
+        if (file != null)
+        {
+            var uploadInput = new UploadFileInput
+            {
+                File = file,
+                BucketName = sysFile.BucketName,
+                FileType = sysFile.FileType
+            };
+
+            return await SelectProviderAsync(file, uploadInput);
+        }
+
+        // 3. 最后兜底:使用默认存储提供者
+        return await _fileProviderService.GetDefaultProvider();
+    }
+
+    /// <summary>
+    /// 选择合适的OSS存储提供者(内联版本)
+    /// </summary>
+    /// <param name="file">上传的文件</param>
+    /// <param name="input">上传输入参数</param>
+    /// <returns></returns>
+    private async Task<SysFileProvider?> SelectProviderAsync(IFormFile file, UploadFileInput input)
+    {
+        // 1. 优先使用指定的提供者ID
+        if (input.ProviderId.HasValue)
+        {
+            var provider = await _fileProviderService.GetFileProviderById(input.ProviderId.Value);
+            if (provider != null) return provider;
+        }
+
+        // 2. 其次使用指定的存储桶名称
+        if (!string.IsNullOrEmpty(input.BucketName))
+        {
+            var providers = await _fileProviderService.GetCachedFileProviders();
+            var provider = providers.FirstOrDefault(p => p.BucketName == input.BucketName);
+            if (provider != null) return provider;
+        }
+
+        // 3. 使用默认提供者
+        var defaultProvider = await _fileProviderService.GetDefaultProvider();
+        if (defaultProvider != null) return defaultProvider;
+
+        // 4. 兜底:如果数据库中没有配置,尝试从配置文件创建默认提供者
+        return await CreateProviderFromConfiguration();
+    }
+
+    /// <summary>
+    /// 生成文件URL(内联版本)
+    /// </summary>
+    /// <param name="provider">存储提供者配置</param>
+    /// <param name="bucketName">存储桶名称</param>
+    /// <param name="filePath">文件路径</param>
+    /// <returns></returns>
+    private static string GenerateFileUrl(SysFileProvider provider, string bucketName, string filePath)
+    {
+        ArgumentNullException.ThrowIfNull(provider);
+        ArgumentException.ThrowIfNullOrWhiteSpace(bucketName);
+        ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
+
+        var protocol = provider.IsEnableHttps == true ? "https" : "http";
+
+        // 如果有自定义域名,直接使用
+        if (!string.IsNullOrWhiteSpace(provider.SinceDomain))
+        {
+            return $"{provider.SinceDomain.TrimEnd('/')}/{filePath.TrimStart('/')}";
+        }
+
+        // 根据不同提供者生成URL
+        return provider.Provider.ToUpper() switch
+        {
+            "ALIYUN" => $"{protocol}://{bucketName}.{provider.Endpoint}/{filePath.TrimStart('/')}",
+            "QCLOUD" => $"{protocol}://{bucketName}-{provider.Endpoint}.cos.{provider.Region}.myqcloud.com/{filePath.TrimStart('/')}",
+            "MINIO" => $"{protocol}://{provider.Endpoint}/{bucketName}/{filePath.TrimStart('/')}",
+             _ => throw Oops.Oh($"不支持的OSS提供者: {provider.Provider}")
+        };
+    }
+
+    /// <summary>
+    /// 从配置文件创建默认提供者(兜底机制)
+    /// </summary>
+    /// <returns></returns>
+    private Task<SysFileProvider?> CreateProviderFromConfiguration()
+    {
+        try
+        {
+            // 检查是否启用了OSS配置
+            if (!_ossProviderOptions.Enabled && !App.Configuration["MultiOSS:Enabled"].ToBoolean())
+                return Task.FromResult<SysFileProvider?>(null);
+
+            // 验证必要配置
+            if (string.IsNullOrWhiteSpace(_ossProviderOptions.AccessKey) ||
+                string.IsNullOrWhiteSpace(_ossProviderOptions.SecretKey) ||
+                string.IsNullOrWhiteSpace(_ossProviderOptions.Bucket))
+            {
+                return Task.FromResult<SysFileProvider?>(null);
+            }
+
+            // 使用现有的OSSProviderOptions创建临时提供者配置(不保存到数据库)
+            var provider = new SysFileProvider
+            {
+                Id = 0, // 临时ID
+                Provider = Enum.GetName(_ossProviderOptions.Provider),
+                BucketName = _ossProviderOptions.Bucket,
+                AccessKey = _ossProviderOptions.AccessKey,
+                SecretKey = _ossProviderOptions.SecretKey,
+                Endpoint = _ossProviderOptions.Endpoint,
+                Region = _ossProviderOptions.Region,
+                IsEnableHttps = _ossProviderOptions.IsEnableHttps,
+                IsEnableCache = _ossProviderOptions.IsEnableCache,
+                IsEnable = true,
+                IsDefault = true,
+                SinceDomain = _ossProviderOptions.CustomHost,
+                CreateTime = DateTime.Now
+            };
+
+            return Task.FromResult<SysFileProvider?>(provider);
+        }
+        catch (Exception)
+        {
+            // 配置读取失败,返回null
+            return Task.FromResult<SysFileProvider?>(null);
+        }
+    }
+}

+ 33 - 11
Admin.NET/Admin.NET.Core/Service/File/FileProvider/SSHFileProvider.cs

@@ -8,35 +8,57 @@ namespace Admin.NET.Core.Service;
 
 public class SSHFileProvider : ICustomFileProvider, ITransient
 {
+    /// <summary>
+    /// 创建SSH连接助手
+    /// </summary>
+    /// <returns></returns>
+    private SSHHelper CreateSSHHelper()
+    {
+        return new SSHHelper(
+            App.Configuration["SSHProvider:Host"],
+            App.Configuration["SSHProvider:Port"].ToInt(),
+            App.Configuration["SSHProvider:Username"],
+            App.Configuration["SSHProvider:Password"]);
+    }
+
+    /// <summary>
+    /// 构建文件完整路径
+    /// </summary>
+    /// <param name="sysFile"></param>
+    /// <returns></returns>
+    private string BuildFilePath(SysFile sysFile)
+    {
+        return string.Concat(sysFile.FilePath, "/", sysFile.Id + sysFile.Suffix);
+    }
+
     public Task DeleteFileAsync(SysFile sysFile)
     {
-        var fullPath = string.Concat(sysFile.FilePath, "/", sysFile.Id + sysFile.Suffix);
-        using SSHHelper helper = new(App.Configuration["SSHProvider:Host"],
-            App.Configuration["SSHProvider:Port"].ToInt(), App.Configuration["SSHProvider:Username"], App.Configuration["SSHProvider:Password"]);
+        var fullPath = BuildFilePath(sysFile);
+        using var helper = CreateSSHHelper();
         helper.DeleteFile(fullPath);
         return Task.CompletedTask;
     }
 
     public Task<string> DownloadFileBase64Async(SysFile sysFile)
     {
-        using SSHHelper helper = new(App.Configuration["SSHProvider:Host"],
-            App.Configuration["SSHProvider:Port"].ToInt(), App.Configuration["SSHProvider:Username"], App.Configuration["SSHProvider:Password"]);
+        using var helper = CreateSSHHelper();
         return Task.FromResult(Convert.ToBase64String(helper.ReadAllBytes(sysFile.FilePath)));
     }
 
     public Task<FileStreamResult> GetFileStreamResultAsync(SysFile sysFile, string fileName)
     {
-        var filePath = Path.Combine(sysFile.FilePath ?? "", sysFile.Id + sysFile.Suffix);
-        using SSHHelper helper = new(App.Configuration["SSHProvider:Host"],
-            App.Configuration["SSHProvider:Port"].ToInt(), App.Configuration["SSHProvider:Username"], App.Configuration["SSHProvider:Password"]);
-        return Task.FromResult(new FileStreamResult(helper.OpenRead(filePath), "application/octet-stream") { FileDownloadName = fileName + sysFile.Suffix });
+        var filePath = BuildFilePath(sysFile);
+        using var helper = CreateSSHHelper();
+        return Task.FromResult(new FileStreamResult(helper.OpenRead(filePath), "application/octet-stream")
+        {
+            FileDownloadName = fileName + sysFile.Suffix
+        });
     }
 
     public Task<SysFile> UploadFileAsync(IFormFile file, SysFile sysFile, string path, string finalName)
     {
         var fullPath = string.Concat(path.StartsWith('/') ? path : "/" + path, "/", finalName);
-        using SSHHelper helper = new(App.Configuration["SSHProvider:Host"],
-            App.Configuration["SSHProvider:Port"].ToInt(), App.Configuration["SSHProvider:Username"], App.Configuration["SSHProvider:Password"]);
+        using var helper = CreateSSHHelper();
         helper.UploadFile(file.OpenReadStream(), fullPath);
         return Task.FromResult(sysFile);
     }

+ 214 - 0
Admin.NET/Admin.NET.Core/Service/File/IOSSServiceManager.cs

@@ -0,0 +1,214 @@
+using OnceMi.AspNetCore.OSS;
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Admin.NET.Core.Service;
+
+/// <summary>
+/// OSS服务管理器接口
+/// </summary>
+public interface IOSSServiceManager : IDisposable
+{
+    /// <summary>
+    /// 获取OSS服务实例
+    /// </summary>
+    /// <param name="provider">存储提供者配置</param>
+    /// <returns></returns>
+    Task<IOSSService> GetOSSServiceAsync(SysFileProvider provider);
+
+    /// <summary>
+    /// 清除缓存
+    /// </summary>
+    void ClearCache();
+}
+
+/// <summary>
+/// OSS服务管理器实现
+/// </summary>
+public class OSSServiceManager : IOSSServiceManager, ITransient
+{
+    private readonly IServiceProvider _serviceProvider;
+    private readonly ConcurrentDictionary<string, IOSSService> _ossServiceCache;
+    private readonly object _lockObject = new object();
+    private bool _disposed = false;
+
+    public OSSServiceManager(IServiceProvider serviceProvider)
+    {
+        _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
+        _ossServiceCache = new ConcurrentDictionary<string, IOSSService>();
+    }
+
+    /// <summary>
+    /// 获取OSS服务实例(带缓存)
+    /// </summary>
+    /// <param name="provider">存储提供者配置</param>
+    /// <returns></returns>
+    public async Task<IOSSService> GetOSSServiceAsync(SysFileProvider provider)
+    {
+        if (provider == null)
+            throw new ArgumentNullException(nameof(provider));
+
+        var cacheKey = provider.ConfigKey;
+
+        // 尝试从缓存获取
+        if (_ossServiceCache.TryGetValue(cacheKey, out var cachedService))
+        {
+            return cachedService;
+        }
+
+        // 验证配置
+        if (!await ValidateConfigurationAsync(provider))
+        {
+            throw new InvalidOperationException($"OSS提供者配置无效: {provider.DisplayName}");
+        }
+
+        // 线程安全地创建新服务
+        lock (_lockObject)
+        {
+            // 双重检查锁定模式
+            if (_ossServiceCache.TryGetValue(cacheKey, out cachedService))
+            {
+                return cachedService;
+            }
+
+            // 转换配置并创建服务
+            var ossOptions = ConvertToOSSOptions(provider);
+            var ossService = CreateOSSService(ossOptions);
+
+            // 添加到缓存
+            _ossServiceCache.TryAdd(cacheKey, ossService);
+
+            return ossService;
+        }
+    }
+
+    /// <summary>
+    /// 创建OSS服务实例
+    /// </summary>
+    /// <param name="options">OSS配置选项</param>
+    /// <returns></returns>
+    private IOSSService CreateOSSService(OSSOptions options)
+    {
+        ArgumentNullException.ThrowIfNull(options);
+
+        try
+        {
+            // 使用现有的IOSSServiceFactory,但需要先注册配置
+            var providerName = Enum.GetName(options.Provider);
+            var configSectionName = $"TempOSS_{Guid.NewGuid():N}";
+
+            // 创建临时配置
+            var configData = new Dictionary<string, string>
+            {
+                [$"{configSectionName}:Provider"] = providerName,
+                [$"{configSectionName}:Endpoint"] = options.Endpoint ?? "",
+                [$"{configSectionName}:AccessKey"] = options.AccessKey ?? "",
+                [$"{configSectionName}:SecretKey"] = options.SecretKey ?? "",
+                [$"{configSectionName}:Region"] = options.Region ?? "",
+                [$"{configSectionName}:IsEnableHttps"] = options.IsEnableHttps.ToString(),
+                [$"{configSectionName}:IsEnableCache"] = options.IsEnableCache.ToString()
+            };
+
+            var tempConfig = new ConfigurationBuilder()
+                .AddInMemoryCollection(configData)
+                .Build();
+
+            // 创建临时服务集合,但不立即释放
+            var services = new ServiceCollection();
+            services.AddSingleton<IConfiguration>(tempConfig);
+            services.AddLogging();
+            services.AddOSSService(providerName, configSectionName);
+
+            // 构建服务提供者并创建OSS服务
+            var tempServiceProvider = services.BuildServiceProvider();
+            var ossServiceFactory = tempServiceProvider.GetRequiredService<IOSSServiceFactory>();
+            var ossService = ossServiceFactory.Create(providerName);
+
+            // 注意:不要释放tempServiceProvider,因为ossService可能依赖它
+            // 这里我们接受这个内存开销,因为缓存会减少创建频率
+
+            return ossService;
+        }
+        catch (Exception ex)
+        {
+            throw Oops.Oh($"创建OSS服务失败: {ex.Message}");
+        }
+    }
+
+    /// <summary>
+    /// 验证配置
+    /// </summary>
+    /// <param name="provider">存储提供者配置</param>
+    /// <returns></returns>
+    private Task<bool> ValidateConfigurationAsync(SysFileProvider provider)
+    {
+        if (provider == null) return Task.FromResult(false);
+
+        // 基本字段验证
+        var isValid = !string.IsNullOrWhiteSpace(provider.Provider) &&
+                     !string.IsNullOrWhiteSpace(provider.BucketName) &&
+                     !string.IsNullOrWhiteSpace(provider.AccessKey) &&
+                     !string.IsNullOrWhiteSpace(provider.SecretKey);
+
+        // Minio额外需要Endpoint
+        if (provider.Provider.ToUpper() == "MINIO")
+        {
+            isValid = isValid && !string.IsNullOrWhiteSpace(provider.Endpoint);
+        }
+
+        return Task.FromResult(isValid);
+    }
+
+    /// <summary>
+    /// 将SysFileProvider转换为OSSOptions
+    /// </summary>
+    /// <param name="provider"></param>
+    /// <returns></returns>
+    private OSSOptions ConvertToOSSOptions(SysFileProvider provider)
+    {
+        if (provider == null)
+            throw new ArgumentNullException(nameof(provider));
+
+        var ossOptions = new OSSOptions
+        {
+            Provider = Enum.Parse<OSSProvider>(provider.Provider),
+            Endpoint = provider.Endpoint,
+            Region = provider.Region,
+            IsEnableHttps = provider.IsEnableHttps ?? true,
+            IsEnableCache = provider.IsEnableCache ?? true
+        };
+
+        // 设置认证信息(所有提供者现在都使用统一的字段)
+        ossOptions.AccessKey = provider.AccessKey;
+        ossOptions.SecretKey = provider.SecretKey;
+
+        return ossOptions;
+    }
+
+    /// <summary>
+    /// 清除缓存
+    /// </summary>
+    public void ClearCache()
+    {
+        lock (_lockObject)
+        {
+            _ossServiceCache.Clear();
+        }
+    }
+
+    /// <summary>
+    /// 释放资源
+    /// </summary>
+    public void Dispose()
+    {
+        if (!_disposed)
+        {
+            lock (_lockObject)
+            {
+                _ossServiceCache.Clear();
+            }
+            _disposed = true;
+        }
+    }
+}

+ 207 - 0
Admin.NET/Admin.NET.Core/Service/File/SysFileProviderController.cs

@@ -0,0 +1,207 @@
+// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
+//
+// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
+//
+// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
+
+namespace Admin.NET.Core.Service;
+
+/// <summary>
+/// 文件存储提供者管理控制器 🧩
+/// </summary>
+[ApiDescriptionSettings(Order = 412, Description = "文件存储提供者管理")]
+public class SysFileProviderController : IDynamicApiController, ITransient
+{
+    private readonly SysFileProviderService _fileProviderService;
+
+    public SysFileProviderController(SysFileProviderService fileProviderService)
+    {
+        _fileProviderService = fileProviderService;
+    }
+
+    /// <summary>
+    /// 获取存储提供者列表 🔖
+    /// </summary>
+    /// <returns></returns>
+    [DisplayName("获取存储提供者列表")]
+    public async Task<List<SysFileProvider>> GetProviderList()
+    {
+        return await _fileProviderService.GetFileProviderList();
+    }
+
+    /// <summary>
+    /// 获取存储提供者分页列表 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [DisplayName("获取存储提供者分页列表")]
+    public async Task<SqlSugarPagedList<SysFileProvider>> GetProviderPage(PageFileProviderInput input)
+    {
+        return await _fileProviderService.GetFileProviderPage(input);
+    }
+
+    /// <summary>
+    /// 获取存储提供者详情 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [DisplayName("获取存储提供者详情")]
+    public async Task<SysFileProvider> GetProvider([FromQuery] QueryFileProviderInput input)
+    {
+        return await _fileProviderService.GetFileProvider(input);
+    }
+
+    /// <summary>
+    /// 添加存储提供者 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [ApiDescriptionSettings(Name = "Add"), HttpPost]
+    [DisplayName("添加存储提供者")]
+    public async Task AddProvider(AddFileProviderInput input)
+    {
+        await _fileProviderService.AddFileProvider(input);
+    }
+
+    /// <summary>
+    /// 更新存储提供者 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [ApiDescriptionSettings(Name = "Update"), HttpPost]
+    [DisplayName("更新存储提供者")]
+    public async Task UpdateProvider(UpdateFileProviderInput input)
+    {
+        await _fileProviderService.UpdateFileProvider(input);
+    }
+
+    /// <summary>
+    /// 删除存储提供者 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [ApiDescriptionSettings(Name = "Delete"), HttpPost]
+    [DisplayName("删除存储提供者")]
+    public async Task DeleteProvider(DeleteFileProviderInput input)
+    {
+        await _fileProviderService.DeleteFileProvider(input);
+    }
+
+
+
+
+
+    /// <summary>
+    /// 根据存储桶名称获取存储提供者 🔖
+    /// </summary>
+    /// <param name="bucketName">存储桶名称</param>
+    /// <returns></returns>
+    [DisplayName("根据存储桶名称获取存储提供者")]
+    public async Task<SysFileProvider?> GetProviderByBucketName(string bucketName)
+    {
+        return await _fileProviderService.GetProviderByBucketName(bucketName);
+    }
+
+    /// <summary>
+    /// 清除存储提供者缓存 🔖
+    /// </summary>
+    /// <returns></returns>
+    [DisplayName("清除存储提供者缓存")]
+    public async Task ClearCache()
+    {
+        await _fileProviderService.ClearCache();
+    }
+
+    /// <summary>
+    /// 批量启用/禁用存储提供者 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [ApiDescriptionSettings(Name = "BatchEnable"), HttpPost]
+    [DisplayName("批量启用/禁用存储提供者")]
+    public async Task BatchEnableProvider(BatchEnableProviderInput input)
+    {
+        foreach (var id in input.Ids)
+        {
+            var provider = await _fileProviderService.GetFileProviderById(id);
+            if (provider != null)
+            {
+                var updateInput = new UpdateFileProviderInput
+                {
+                    Id = id,
+                    Provider = provider.Provider,
+                    BucketName = provider.BucketName,
+                    IsEnable = input.IsEnable
+                };
+                await _fileProviderService.UpdateFileProvider(updateInput);
+            }
+        }
+    }
+
+    /// <summary>
+    /// 获取存储提供者统计信息 🔖
+    /// </summary>
+    /// <returns></returns>
+    [DisplayName("获取存储提供者统计信息")]
+    public async Task<object> GetProviderStatistics()
+    {
+        var providers = await _fileProviderService.GetCachedFileProviders();
+
+        var statistics = new
+        {
+            Total = providers.Count,
+            Enabled = providers.Count(p => p.IsEnable == true),
+            Disabled = providers.Count(p => p.IsEnable != true),
+            ByProvider = providers.GroupBy(p => p.Provider)
+                .Select(g => new { Provider = g.Key, Count = g.Count() })
+                .ToList(),
+            ByRegion = providers.Where(p => !string.IsNullOrEmpty(p.Region))
+                .GroupBy(p => p.Region)
+                .Select(g => new { Region = g.Key, Count = g.Count() })
+                .ToList()
+        };
+
+        return statistics;
+    }
+
+    /// <summary>
+    /// 获取所有可用的存储桶列表 🔖
+    /// </summary>
+    /// <returns></returns>
+    [DisplayName("获取所有可用的存储桶列表")]
+    public async Task<List<string>> GetAvailableBuckets()
+    {
+        return await _fileProviderService.GetAvailableBuckets();
+    }
+
+    /// <summary>
+    /// 获取存储桶和提供者的映射关系 🔖
+    /// </summary>
+    /// <returns></returns>
+    [DisplayName("获取存储桶和提供者的映射关系")]
+    public async Task<Dictionary<string, List<SysFileProvider>>> GetBucketProviderMapping()
+    {
+        return await _fileProviderService.GetBucketProviderMapping();
+    }
+
+
+}
+
+
+
+/// <summary>
+/// 批量启用/禁用存储提供者输入参数
+/// </summary>
+public class BatchEnableProviderInput
+{
+    /// <summary>
+    /// 存储提供者ID列表
+    /// </summary>
+    [Required]
+    public List<long> Ids { get; set; }
+
+    /// <summary>
+    /// 是否启用
+    /// </summary>
+    public bool IsEnable { get; set; }
+}

+ 524 - 0
Admin.NET/Admin.NET.Core/Service/File/SysFileProviderService.cs

@@ -0,0 +1,524 @@
+// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
+//
+// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
+//
+// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
+
+using OnceMi.AspNetCore.OSS;
+using Newtonsoft.Json;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Admin.NET.Core.Service;
+
+/// <summary>
+/// 系统文件存储提供者服务 🧩
+/// </summary>
+[ApiDescriptionSettings(Order = 411, Description = "文件存储提供者")]
+public class SysFileProviderService : IDynamicApiController, ITransient
+{
+    private readonly UserManager _userManager;
+    private readonly SqlSugarRepository<SysFileProvider> _sysFileProviderRep;
+    private readonly SysCacheService _sysCacheService;
+    private readonly IOSSServiceFactory _ossServiceFactory;
+    private readonly IOSSServiceManager _ossServiceManager;
+    private static readonly string CacheKey = "sys_file_provider";
+
+    public SysFileProviderService(UserManager userManager,
+        SqlSugarRepository<SysFileProvider> sysFileProviderRep,
+        SysCacheService sysCacheService,
+        IOSSServiceFactory ossServiceFactory,
+        IOSSServiceManager ossServiceManager)
+    {
+        _userManager = userManager;
+        _sysFileProviderRep = sysFileProviderRep;
+        _sysCacheService = sysCacheService;
+        _ossServiceFactory = ossServiceFactory;
+        _ossServiceManager = ossServiceManager;
+    }
+
+    /// <summary>
+    /// 获取文件存储提供者分页列表 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [DisplayName("获取文件存储提供者分页列表")]
+    [NonAction]
+    public async Task<SqlSugarPagedList<SysFileProvider>> GetFileProviderPage(PageFileProviderInput input)
+    {
+        return await _sysFileProviderRep.AsQueryable()
+            .WhereIF(!string.IsNullOrWhiteSpace(input.Provider), u => u.Provider.Contains(input.Provider!))
+            .WhereIF(!string.IsNullOrWhiteSpace(input.BucketName), u => u.BucketName.Contains(input.BucketName!))
+            .WhereIF(input.IsEnable.HasValue, u => u.IsEnable == input.IsEnable)
+            .OrderBy(u => u.OrderNo)
+            .OrderBy(u => u.Id)
+            .ToPagedListAsync(input.Page, input.PageSize);
+    }
+
+    /// <summary>
+    /// 获取文件存储提供者列表 🔖
+    /// </summary>
+    /// <returns></returns>
+    [DisplayName("获取文件存储提供者列表")]
+    [NonAction]
+    public async Task<List<SysFileProvider>> GetFileProviderList()
+    {
+        return await _sysFileProviderRep.AsQueryable()
+            .Where(u => u.IsEnable == true)
+            .OrderBy(u => u.OrderNo)
+            .OrderBy(u => u.Id)
+            .ToListAsync();
+    }
+
+    /// <summary>
+    /// 增加文件存储提供者 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [ApiDescriptionSettings(Name = "Add"), HttpPost]
+    [DisplayName("增加文件存储提供者")]
+    [NonAction]
+    public async Task AddFileProvider(AddFileProviderInput input)
+    {
+        // 验证输入参数
+        if (input == null)
+            throw Oops.Oh("输入参数不能为空").StatusCode(400);
+
+        if (string.IsNullOrWhiteSpace(input.Provider))
+            throw Oops.Oh("存储提供者不能为空").StatusCode(400);
+
+        if (string.IsNullOrWhiteSpace(input.BucketName))
+            throw Oops.Oh("存储桶名称不能为空").StatusCode(400);
+
+        // 验证提供者类型
+        if (!Enum.TryParse<OSSProvider>(input.Provider, true, out _))
+            throw Oops.Oh($"不支持的存储提供者类型: {input.Provider}").StatusCode(400);
+
+        var isExist = await _sysFileProviderRep.AsQueryable()
+            .AnyAsync(u => u.Provider == input.Provider && u.BucketName == input.BucketName);
+        if (isExist)
+            throw Oops.Oh(ErrorCodeEnum.D1006).StatusCode(400);
+
+        var fileProvider = input.Adapt<SysFileProvider>();
+
+        // 验证配置完整性
+        await ValidateProviderConfiguration(fileProvider);
+
+        // 处理默认提供者逻辑
+        await HandleDefaultProviderLogic(fileProvider);
+
+        await _sysFileProviderRep.InsertAsync(fileProvider);
+
+        // 清除缓存
+        await ClearCache();
+
+        // 清除OSS服务缓存
+        _ossServiceManager?.ClearCache();
+    }
+
+    /// <summary>
+    /// 更新文件存储提供者 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [ApiDescriptionSettings(Name = "Update"), HttpPost]
+    [DisplayName("更新文件存储提供者")]
+    [NonAction]
+    public async Task UpdateFileProvider(UpdateFileProviderInput input)
+    {
+        // 验证输入参数
+        if (input == null)
+            throw Oops.Oh("输入参数不能为空").StatusCode(400);
+
+        var isExist = await _sysFileProviderRep.AsQueryable()
+            .AnyAsync(u => u.Provider == input.Provider && u.BucketName == input.BucketName && u.Id != input.Id);
+        if (isExist)
+            throw Oops.Oh(ErrorCodeEnum.D1006).StatusCode(400);
+
+        var fileProvider = input.Adapt<SysFileProvider>();
+
+        // 验证配置完整性
+        await ValidateProviderConfiguration(fileProvider);
+
+        // 处理默认提供者逻辑
+        await HandleDefaultProviderLogic(fileProvider);
+
+        await _sysFileProviderRep.AsUpdateable(fileProvider).IgnoreColumns(ignoreAllNullColumns: true).ExecuteCommandAsync();
+
+        // 清除缓存
+        await ClearCache();
+
+        // 清除OSS服务缓存
+        _ossServiceManager?.ClearCache();
+    }
+
+    /// <summary>
+    /// 删除文件存储提供者 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [ApiDescriptionSettings(Name = "Delete"), HttpPost]
+    [DisplayName("删除文件存储提供者")]
+    [NonAction]
+    public async Task DeleteFileProvider(DeleteFileProviderInput input)
+    {
+        // 检查是否为默认提供者
+        var provider = await _sysFileProviderRep.GetByIdAsync(input.Id) ?? throw Oops.Oh("存储提供者不存在").StatusCode(400);
+        var isDefault = provider.IsDefault == true;
+
+        await _sysFileProviderRep.DeleteByIdAsync(input.Id);
+
+        // 如果删除的是默认提供者,自动设置第一个启用的提供者为默认
+        if (isDefault)
+        {
+            var firstEnabledProvider = await _sysFileProviderRep.AsQueryable()
+                .Where(p => p.IsEnable == true)
+                .OrderBy(p => p.OrderNo)
+                .OrderBy(p => p.Id)
+                .FirstAsync();
+
+            if (firstEnabledProvider != null)
+            {
+                await _sysFileProviderRep.AsUpdateable()
+                    .SetColumns(p => p.IsDefault == true)
+                    .Where(p => p.Id == firstEnabledProvider.Id)
+                    .ExecuteCommandAsync();
+
+                Debug.WriteLine($"自动设置新的默认提供者: {firstEnabledProvider.DisplayName}");
+            }
+        }
+
+        // 清除缓存
+        await ClearCache();
+
+        // 清除OSS服务缓存
+        _ossServiceManager?.ClearCache();
+    }
+
+    /// <summary>
+    /// 获取文件存储提供者详情 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [DisplayName("获取文件存储提供者详情")]
+    [NonAction]
+    public async Task<SysFileProvider> GetFileProvider([FromQuery] QueryFileProviderInput input)
+    {
+        return await _sysFileProviderRep.GetFirstAsync(u => u.Id == input.Id);
+    }
+
+    /// <summary>
+    /// 根据提供者和存储桶获取配置
+    /// </summary>
+    /// <param name="provider"></param>
+    /// <param name="bucketName"></param>
+    /// <returns></returns>
+    [NonAction]
+    public async Task<SysFileProvider?> GetFileProviderByBucket(string provider, string bucketName)
+    {
+        var providers = await GetCachedFileProviders();
+        return providers.FirstOrDefault(x => x.Provider == provider && x.BucketName == bucketName && x.IsEnable == true);
+    }
+
+    /// <summary>
+    /// 根据ID获取配置
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    [NonAction]
+    public async Task<SysFileProvider?> GetFileProviderById(long id)
+    {
+        var providers = await GetCachedFileProviders();
+        return providers.FirstOrDefault(x => x.Id == id && x.IsEnable == true);
+    }
+
+    /// <summary>
+    /// 根据存储桶名称获取存储提供者
+    /// </summary>
+    /// <param name="bucketName">存储桶名称</param>
+    /// <returns></returns>
+    [NonAction]
+    public async Task<SysFileProvider?> GetProviderByBucketName(string bucketName)
+    {
+        if (string.IsNullOrWhiteSpace(bucketName))
+            return null;
+
+        var providers = await GetCachedFileProviders();
+        return providers.FirstOrDefault(p => p.BucketName == bucketName);
+    }
+
+    /// <summary>
+    /// 获取默认存储提供者
+    /// </summary>
+    /// <returns></returns>
+    [NonAction]
+    public async Task<SysFileProvider?> GetDefaultProvider()
+    {
+        var providers = await GetCachedFileProviders();
+
+        // 优先返回标记为默认的提供者
+        var defaultProvider = providers.FirstOrDefault(p => p.IsDefault == true);
+        if (defaultProvider != null)
+            return defaultProvider;
+
+        // 如果没有标记为默认的,返回第一个启用的提供者(兼容旧逻辑)
+        return providers.FirstOrDefault();
+    }
+
+    /// <summary>
+    /// 获取默认存储提供者信息 🔖
+    /// </summary>
+    /// <returns></returns>
+    [DisplayName("获取默认存储提供者信息")]
+    [NonAction]
+    public async Task<SysFileProvider?> GetDefaultProviderInfo()
+    {
+        return await GetDefaultProvider();
+    }
+
+    /// <summary>
+    /// 设置默认存储提供者 🔖
+    /// </summary>
+    /// <param name="input"></param>
+    /// <returns></returns>
+    [ApiDescriptionSettings(Name = "SetDefault"), HttpPost]
+    [DisplayName("设置默认存储提供者")]
+    [NonAction]
+    public async Task SetDefaultProvider(SetDefaultProviderInput input)
+    {
+        // 验证提供者是否存在且启用
+        var provider = await _sysFileProviderRep.GetByIdAsync(input.Id) ?? throw Oops.Oh("存储提供者不存在").StatusCode(400);
+        if (provider.IsEnable != true)
+            throw Oops.Oh("只能设置启用状态的存储提供者为默认").StatusCode(400);
+
+        // 开启事务,确保数据一致性
+        await _sysFileProviderRep.AsTenant().BeginTranAsync();
+        try
+        {
+            // 先将所有提供者的默认标识设为false
+            await _sysFileProviderRep.AsUpdateable()
+                .SetColumns(p => p.IsDefault == false)
+                .Where(p => p.IsDefault == true)
+                .ExecuteCommandAsync();
+
+            // 设置指定提供者为默认
+            await _sysFileProviderRep.AsUpdateable()
+                .SetColumns(p => p.IsDefault == true)
+                .Where(p => p.Id == input.Id)
+                .ExecuteCommandAsync();
+
+            await _sysFileProviderRep.AsTenant().CommitTranAsync();
+
+            // 清除缓存
+            await ClearCache();
+
+            // 清除OSS服务缓存
+            _ossServiceManager?.ClearCache();
+
+            Debug.WriteLine($"已设置默认存储提供者: {provider.DisplayName}");
+        }
+        catch (Exception)
+        {
+            await _sysFileProviderRep.AsTenant().RollbackTranAsync();
+            throw;
+        }
+    }
+
+    /// <summary>
+    /// 获取缓存的文件提供者列表
+    /// </summary>
+    /// <returns></returns>
+    [NonAction]
+    public async Task<List<SysFileProvider>> GetCachedFileProviders()
+    {
+        return await _sysCacheService.AdGetAsync(CacheKey, async () =>
+        {
+            return await _sysFileProviderRep.AsQueryable()
+                .Where(u => u.IsEnable == true)
+                .OrderBy(u => u.OrderNo)
+                .OrderBy(u => u.Id)
+                .ToListAsync();
+        }, TimeSpan.FromMinutes(30));
+    }
+
+    /// <summary>
+    /// 清除缓存
+    /// </summary>
+    /// <returns></returns>
+    [NonAction]
+    public async Task ClearCache()
+    {
+        _sysCacheService.Remove(CacheKey);
+        await Task.CompletedTask;
+    }
+
+
+    /// <summary>
+    /// 获取所有可用的存储桶列表
+    /// </summary>
+    /// <returns></returns>
+    [NonAction]
+    public async Task<List<string>> GetAvailableBuckets()
+    {
+        var providers = await GetCachedFileProviders();
+        return providers.Select(p => p.BucketName).Distinct().OrderBy(b => b).ToList();
+    }
+
+    /// <summary>
+    /// 获取存储桶和提供者的映射关系
+    /// </summary>
+    /// <returns></returns>
+    [NonAction]
+    public async Task<Dictionary<string, List<SysFileProvider>>> GetBucketProviderMapping()
+    {
+        var providers = await GetCachedFileProviders();
+        var mapping = new Dictionary<string, List<SysFileProvider>>();
+
+        foreach (var provider in providers)
+        {
+            if (!mapping.TryGetValue(provider.BucketName, out List<SysFileProvider> value))
+            {
+                value = new List<SysFileProvider>();
+                mapping[provider.BucketName] = value;
+            }
+
+            value.Add(provider);
+        }
+
+        return mapping;
+    }
+
+    /// <summary>
+    /// 验证存储提供者配置
+    /// </summary>
+    /// <param name="provider">存储提供者配置</param>
+    /// <returns></returns>
+    [NonAction]
+    private async Task ValidateProviderConfiguration(SysFileProvider provider)
+    {
+        if (provider == null)
+            throw Oops.Oh("存储提供者配置不能为空").StatusCode(400);
+
+        // 基础字段验证
+        if (string.IsNullOrWhiteSpace(provider.Provider))
+            throw Oops.Oh("存储提供者类型不能为空").StatusCode(400);
+
+        if (string.IsNullOrWhiteSpace(provider.BucketName))
+            throw Oops.Oh("存储桶名称不能为空").StatusCode(400);
+
+        if (string.IsNullOrWhiteSpace(provider.Endpoint))
+            throw Oops.Oh("端点地址不能为空").StatusCode(400);
+
+        // 所有提供者都需要AccessKey和SecretKey
+        if (string.IsNullOrWhiteSpace(provider.AccessKey))
+            throw Oops.Oh($"{provider.Provider} AccessKey不能为空").StatusCode(400);
+        if (string.IsNullOrWhiteSpace(provider.SecretKey))
+            throw Oops.Oh($"{provider.Provider} SecretKey不能为空").StatusCode(400);
+
+        // 根据不同提供者验证特定字段
+        switch (provider.Provider.ToUpper())
+        {
+            case "ALIYUN":
+                if (string.IsNullOrWhiteSpace(provider.Region))
+                    throw Oops.Oh("阿里云Region不能为空").StatusCode(400);
+                break;
+
+            case "QCLOUD":
+                if (string.IsNullOrWhiteSpace(provider.Endpoint))
+                    throw Oops.Oh("腾讯云Endpoint(AppId)不能为空").StatusCode(400);
+                if (string.IsNullOrWhiteSpace(provider.Region))
+                    throw Oops.Oh("腾讯云Region不能为空").StatusCode(400);
+                break;
+
+            case "MINIO":
+                // Minio只需要AccessKey和SecretKey,已在上面验证
+                break;
+
+            default:
+                throw Oops.Oh($"不支持的存储提供者类型: {provider.Provider}").StatusCode(400);
+        }
+
+        // 验证存储桶名称格式
+        await ValidateBucketName(provider.Provider, provider.BucketName);
+    }
+
+    /// <summary>
+    /// 验证存储桶名称格式
+    /// </summary>
+    /// <param name="provider">存储提供者类型</param>
+    /// <param name="bucketName">存储桶名称</param>
+    /// <returns></returns>
+    [NonAction]
+    private async Task ValidateBucketName(string provider, string bucketName)
+    {
+        if (string.IsNullOrWhiteSpace(bucketName))
+            return;
+
+        switch (provider.ToUpper())
+        {
+            case "ALIYUN":
+                // 阿里云存储桶命名规则
+                if (bucketName.Length < 3 || bucketName.Length > 63)
+                    throw Oops.Oh("阿里云存储桶名称长度必须在3-63字符之间").StatusCode(400);
+
+                if (!Regex.IsMatch(bucketName, @"^[a-z0-9][a-z0-9\-]*[a-z0-9]$"))
+                    throw Oops.Oh("阿里云存储桶名称只能包含小写字母、数字和短横线,且必须以字母或数字开头和结尾").StatusCode(400);
+                break;
+
+            case "QCLOUD":
+                // 腾讯云存储桶命名规则
+                if (bucketName.Length < 1 || bucketName.Length > 40)
+                    throw Oops.Oh("腾讯云存储桶名称长度必须在1-40字符之间").StatusCode(400);
+
+                if (!Regex.IsMatch(bucketName, @"^[a-z0-9][a-z0-9\-]*[a-z0-9]$"))
+                    throw Oops.Oh("腾讯云存储桶名称只能包含小写字母、数字和短横线,且必须以字母或数字开头和结尾").StatusCode(400);
+                break;
+
+            case "MINIO":
+                // Minio存储桶命名规则
+                if (bucketName.Length < 3 || bucketName.Length > 63)
+                    throw Oops.Oh("Minio存储桶名称长度必须在3-63字符之间").StatusCode(400);
+
+                if (!Regex.IsMatch(bucketName, @"^[a-z0-9][a-z0-9\-\.]*[a-z0-9]$"))
+                    throw Oops.Oh("Minio存储桶名称只能包含小写字母、数字、短横线和点,且必须以字母或数字开头和结尾").StatusCode(400);
+                break;
+        }
+
+        await Task.CompletedTask;
+    }
+
+    /// <summary>
+    /// 处理默认提供者逻辑
+    /// </summary>
+    /// <param name="provider">存储提供者配置</param>
+    /// <returns></returns>
+    [NonAction]
+    private async Task HandleDefaultProviderLogic(SysFileProvider provider)
+    {
+        // 如果设置为默认提供者
+        if (provider.IsDefault == true)
+        {
+            // 确保只有一个默认提供者,将其他提供者的默认标识设为false
+            await _sysFileProviderRep.AsUpdateable()
+                .SetColumns(p => p.IsDefault == false) 
+                .Where(p => p.IsDefault == true && p.Id != provider.Id)
+                .ExecuteCommandAsync();
+        }
+        else
+        // 如果没有设置IsDefault值,默认为false
+        {
+            provider.IsDefault ??= false;
+        }
+           
+
+        // 检查是否还有其他默认提供者,如果没有且当前提供者启用,则设为默认
+        var hasDefaultProvider = await _sysFileProviderRep.AsQueryable()
+            .Where(p => p.IsDefault == true && p.IsEnable == true && p.Id != provider.Id)
+            .AnyAsync();
+
+        if (!hasDefaultProvider && provider.IsEnable == true && provider.IsDefault != true)
+        {
+            // 如果没有其他默认提供者且当前提供者启用,则设为默认
+            provider.IsDefault = true;
+        }
+    }
+}

+ 59 - 13
Admin.NET/Admin.NET.Core/Service/File/SysFileService.cs

@@ -7,6 +7,7 @@
 using Aliyun.OSS.Util;
 using Furion.AspNetCore;
 using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.Configuration;
 
 namespace Admin.NET.Core.Service;
 
@@ -20,6 +21,7 @@ public class SysFileService : IDynamicApiController, ITransient
     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;
@@ -27,18 +29,24 @@ public class SysFileService : IDynamicApiController, ITransient
     public SysFileService(UserManager userManager,
         SqlSugarRepository<SysFile> sysFileRep,
         IOptions<OSSProviderOptions> oSSProviderOptions,
-        IOptions<UploadOptions> uploadOptions, INamedServiceProvider<ICustomFileProvider> namedServiceProvider)
+        IOptions<UploadOptions> uploadOptions,
+        INamedServiceProvider<ICustomFileProvider> namedServiceProvider,
+        IConfiguration configuration)
     {
         _namedServiceProvider = namedServiceProvider;
         _userManager = userManager;
         _sysFileRep = sysFileRep;
         _OSSProviderOptions = oSSProviderOptions.Value;
         _uploadOptions = uploadOptions.Value;
-        if (_OSSProviderOptions.Enabled)
+        _configuration = configuration;
+
+        // 简化提供者选择逻辑
+        if (_OSSProviderOptions.Enabled || _configuration["MultiOSS:Enabled"].ToBoolean())
         {
-            _customFileProvider = _namedServiceProvider.GetService<ITransient>(nameof(OSSFileProvider));
+            // 统一使用MultiOSSFileProvider处理所有OSS情况
+            _customFileProvider = _namedServiceProvider.GetService<ITransient>(nameof(MultiOSSFileProvider));
         }
-        else if (App.Configuration["SSHProvider:Enabled"].ToBoolean())
+        else if (_configuration["SSHProvider:Enabled"].ToBoolean())
         {
             _customFileProvider = _namedServiceProvider.GetService<ITransient>(nameof(SSHFileProvider));
         }
@@ -87,7 +95,7 @@ public class SysFileService : IDynamicApiController, ITransient
         if (string.IsNullOrEmpty(input.FileName))
             input.FileName = $"{YitIdHelper.NextId()}.{contentType.AsSpan(contentType.LastIndexOf('/') + 1)}";
 
-        var ms = new MemoryStream();
+        using var ms = new MemoryStream();
         ms.Write(fileData);
         ms.Seek(0, SeekOrigin.Begin);
         IFormFile formFile = new FormFile(ms, 0, fileData.Length, "file", input.FileName)
@@ -106,10 +114,14 @@ public class SysFileService : IDynamicApiController, ITransient
     /// <param name="files"></param>
     /// <returns></returns>
     [DisplayName("上传多文件")]
-    public List<SysFile> UploadFiles([Required] List<IFormFile> files)
+    public async Task<List<SysFile>> UploadFiles([Required] List<IFormFile> files)
     {
         var fileList = new List<SysFile>();
-        files.ForEach(file => fileList.Add(UploadFile(new UploadFileInput { File = file }).Result));
+        foreach (var file in files)
+        {
+            var uploadedFile = await UploadFile(new UploadFileInput { File = file });
+            fileList.Add(uploadedFile);
+        }
         return fileList;
     }
 
@@ -221,8 +233,11 @@ public class SysFileService : IDynamicApiController, ITransient
     [DisplayName("获取文件路径")]
     public async Task<List<TreeNode>> GetFolder()
     {
-        var files = await _sysFileRep.AsQueryable().ToListAsync();
-        var folders = files.GroupBy(u => u.FilePath).Select(u => u.First().FilePath).ToList();
+        // 优化:直接在数据库层面获取不重复的文件路径
+        var folders = await _sysFileRep.AsQueryable()
+            .Select(u => u.FilePath)
+            .Distinct()
+            .ToListAsync();
 
         var pathTreeBuilder = new PathTreeBuilder();
         var tree = pathTreeBuilder.BuildTree(folders);
@@ -287,7 +302,18 @@ public class SysFileService : IDynamicApiController, ITransient
 
         var newFile = input.Adapt<SysFile>();
         newFile.Id = YitIdHelper.NextId();
-        newFile.BucketName = _OSSProviderOptions.Enabled ? _OSSProviderOptions.Bucket : "Local"; // 阿里云对bucket名称有要求,1.只能包括小写字母,数字,短横线(-)2.必须以小写字母或者数字开头  3.长度必须在3-63字节之间
+
+        // 优先使用用户指定的存储桶名称,如果没有指定则使用默认配置
+        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;
@@ -318,7 +344,17 @@ public class SysFileService : IDynamicApiController, ITransient
         if (!string.IsNullOrWhiteSpace(user.Avatar))
         {
             var fileId = Path.GetFileNameWithoutExtension(user.Avatar);
-            await DeleteFile(new BaseIdInput { Id = long.Parse(fileId) });
+            if (long.TryParse(fileId, out var id))
+            {
+                try
+                {
+                    await DeleteFile(new BaseIdInput { Id = id });
+                }
+                catch
+                {
+                    // 忽略删除旧头像文件的错误,不影响新头像上传
+                }
+            }
         }
 
         return sysFile;
@@ -340,7 +376,17 @@ public class SysFileService : IDynamicApiController, ITransient
         if (!string.IsNullOrWhiteSpace(user.Signature) && user.Signature.EndsWith(".png"))
         {
             var fileId = Path.GetFileNameWithoutExtension(user.Signature);
-            await DeleteFile(new BaseIdInput { Id = long.Parse(fileId) });
+            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;
@@ -375,7 +421,7 @@ public class SysFileService : IDynamicApiController, ITransient
     /// <param name="dataId"></param>
     /// <returns></returns>
     [NonAction]
-    public async Task DeteleFileByDataId(long dataId)
+    public async Task DeleteFileByDataId(long dataId)
     {
         // 删除冗余无效的物理文件
         var tmpFiles = await _sysFileRep.GetListAsync(u => u.DataId == dataId);