feat(storage): refactor storage service and provider interface, support dynamic registration of storage providers

This commit is contained in:
ShiYu
2025-05-22 16:31:32 +08:00
parent cafe48402e
commit 9243a26189
15 changed files with 237 additions and 105 deletions

View File

@@ -0,0 +1,33 @@
using Foxel.Models.DataBase;
namespace Foxel.Services.Attributes;
public enum StorageType
{
Local = 0,
Telegram = 1,
S3 = 2,
Cos = 3,
}
/// <summary>
/// 标记存储提供者类的特性
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class StorageProviderAttribute : Attribute
{
/// <summary>
/// 存储类型
/// </summary>
public StorageType StorageType { get; }
/// <summary>
/// 构造函数
/// </summary>
/// <param name="storageType">存储类型</param>
public StorageProviderAttribute(StorageType storageType)
{
StorageType = storageType;
}
}

View File

@@ -2,9 +2,9 @@ using System.Collections.Concurrent;
using System.Threading.Channels;
using Microsoft.EntityFrameworkCore;
using Foxel.Models.DataBase;
using Foxel.Services.Attributes;
using Foxel.Services.Interface;
using Foxel.Utils;
using Foxel.Services.StorageProvider;
namespace Foxel.Services;
@@ -200,9 +200,8 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
try
{
using var scope = _serviceProvider.CreateScope();
var pictureService = scope.ServiceProvider.GetRequiredService<IPictureService>();
var aiService = scope.ServiceProvider.GetRequiredService<IAiService>();
var storageProviderFactory = scope.ServiceProvider.GetRequiredService<IStorageProviderFactory>();
var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>();
// 1. 获取图片信息
await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 10);
@@ -214,9 +213,6 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
throw new Exception($"找不到ID为{task.PictureId}的图片");
}
// 根据存储类型获取文件处理路径
var storageProvider = storageProviderFactory.GetProvider(picture.StorageType);
// 处理文件获取逻辑
if (picture.StorageType == StorageType.Local)
{
@@ -227,7 +223,7 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
{
// 非本地存储需要先下载文件
await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 15);
localFilePath = await storageProvider.DownloadFileAsync(picture.Path);
localFilePath = await storageService.DownloadFileAsync(picture.StorageType, picture.Path);
isTempFile = true;
}
@@ -257,12 +253,13 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
else
{
// 非本地存储,上传缩略图到对应的存储服务
using var thumbnailFileStream = new FileStream(thumbnailPath, FileMode.Open, FileAccess.Read);
await using var thumbnailFileStream = new FileStream(thumbnailPath, FileMode.Open, FileAccess.Read);
var thumbnailFileName = Path.GetFileName(thumbnailPath);
var thumbnailContentType = Path.GetExtension(thumbnailPath).ToLower() == ".png" ? "image/png" : "image/jpeg";
// 上传缩略图并获取存储路径或元数据
string thumbnailStoragePath = await storageProvider.SaveAsync(
string thumbnailStoragePath = await storageService.SaveAsync(
picture.StorageType,
thumbnailFileStream,
thumbnailFileName,
thumbnailContentType);

View File

@@ -1,6 +1,7 @@
using Foxel.Models;
using Foxel.Models.DataBase;
using Foxel.Models.Response.Picture;
using Foxel.Services.Attributes;
namespace Foxel.Services.Interface;

View File

@@ -1,14 +0,0 @@
using Foxel.Models.DataBase;
using Foxel.Services.Interface;
namespace Foxel.Services.Interface;
public interface IStorageProviderFactory
{
/// <summary>
/// 根据存储类型获取对应的存储提供者
/// </summary>
/// <param name="storageType">存储类型</param>
/// <returns>存储提供者实例</returns>
IStorageProvider GetProvider(StorageType storageType);
}

View File

@@ -0,0 +1,50 @@
using Foxel.Models.DataBase;
using Foxel.Services.Attributes;
namespace Foxel.Services.Interface;
/// <summary>
/// 统一的存储服务接口
/// </summary>
public interface IStorageService
{
/// <summary>
/// 根据存储类型获取对应的存储提供者
/// </summary>
/// <param name="storageType">存储类型</param>
/// <returns>存储提供者实例</returns>
IStorageProvider GetProvider(StorageType storageType);
/// <summary>
/// 使用指定存储类型保存文件
/// </summary>
/// <param name="storageType">存储类型</param>
/// <param name="fileStream">文件流</param>
/// <param name="fileName">文件名</param>
/// <param name="contentType">内容类型</param>
/// <returns>存储路径</returns>
Task<string> SaveAsync(StorageType storageType, Stream fileStream, string fileName, string contentType);
/// <summary>
/// 使用指定存储类型删除文件
/// </summary>
/// <param name="storageType">存储类型</param>
/// <param name="storagePath">存储路径</param>
Task DeleteAsync(StorageType storageType, string storagePath);
/// <summary>
/// 使用指定存储类型获取文件URL
/// </summary>
/// <param name="storageType">存储类型</param>
/// <param name="storagePath">存储路径</param>
/// <returns>文件URL</returns>
string GetUrl(StorageType storageType, string storagePath);
/// <summary>
/// 使用指定存储类型下载文件
/// </summary>
/// <param name="storageType">存储类型</param>
/// <param name="storagePath">存储路径</param>
/// <returns>本地文件路径</returns>
Task<string> DownloadFileAsync(StorageType storageType, string storagePath);
}

View File

@@ -7,6 +7,7 @@ using Pgvector;
using Pgvector.EntityFrameworkCore;
using System.Text.Json;
using Foxel.Models.Response.Picture;
using Foxel.Services.Attributes;
namespace Foxel.Services;
@@ -15,7 +16,7 @@ public class PictureService(
IAiService embeddingService,
IConfigService configuration,
IBackgroundTaskQueue backgroundTaskQueue,
IStorageProviderFactory storageProviderFactory)
IStorageService storageService)
: IPictureService
{
private readonly string _serverUrl = configuration["AppSettings:ServerUrl"];
@@ -334,14 +335,12 @@ public class PictureService(
// 将数据库实体映射到响应对象
private PictureResponse MapPictureToResponse(Picture picture, string serverUrl)
{
var storageProvider = storageProviderFactory.GetProvider(picture.StorageType);
return new PictureResponse
{
Id = picture.Id,
Name = picture.Name,
Path = storageProvider.GetUrl(picture.Path),
ThumbnailPath = storageProvider.GetUrl(picture.ThumbnailPath),
Path = storageService.GetUrl(picture.StorageType, picture.Path),
ThumbnailPath = storageService.GetUrl(picture.StorageType, picture.ThumbnailPath),
Description = picture.Description,
CreatedAt = picture.CreatedAt,
Tags = picture.Tags != null ? picture.Tags.Select(t => t.Name).ToList() : new List<string>(),
@@ -452,11 +451,8 @@ public class PictureService(
string fileExtension = Path.GetExtension(fileName);
string newFileName = $"{Guid.NewGuid()}{fileExtension}";
// 获取对应的存储提供者
var storageProvider = storageProviderFactory.GetProvider(storageType.Value);
// 使用存储提供者保存文件
string relativePath = await storageProvider.SaveAsync(fileStream, fileName, contentType);
// 使用存储服务保存文件
string relativePath = await storageService.SaveAsync(storageType.Value, fileStream, fileName, contentType);
// 创建基本的Picture对象使用文件名作为标题和描述
string initialTitle = Path.GetFileNameWithoutExtension(fileName);
@@ -522,8 +518,8 @@ public class PictureService(
{
Id = picture.Id,
Name = picture.Name,
Path = storageProvider.GetUrl(relativePath),
ThumbnailPath = isAnonymous ? storageProvider.GetUrl(relativePath) : null,
Path = storageService.GetUrl(picture.StorageType, relativePath),
ThumbnailPath = isAnonymous ? storageService.GetUrl(picture.StorageType, relativePath) : null,
Description = picture.Description,
CreatedAt = picture.CreatedAt,
Tags = new List<string>(),
@@ -602,21 +598,13 @@ public class PictureService(
try
{
// 根据存储类型获取相应的存储提供者并删除文件
var storageProvider = storageProviderFactory.GetProvider(storageType);
await storageProvider.DeleteAsync(path);
// 使用存储服务删除文件
await storageService.DeleteAsync(storageType, path);
// 删除缩略图
if (storageType == StorageType.Local)
if (!string.IsNullOrEmpty(thumbnailPath))
{
// 对于本地存储,使用本地存储提供者删除缩略图
await storageProvider.DeleteAsync(thumbnailPath);
}
else
{
// 对于其他存储类型(如Telegram),使用相同的存储提供者删除缩略图
// 因为缩略图元数据格式与原文件相同
await storageProvider.DeleteAsync(thumbnailPath);
await storageService.DeleteAsync(storageType, thumbnailPath);
}
}
catch (Exception ex)
@@ -697,8 +685,8 @@ public class PictureService(
{
Id = picture.Id,
Name = picture.Name,
Path = storageProviderFactory.GetProvider(picture.StorageType).GetUrl(picture.Path),
ThumbnailPath = storageProviderFactory.GetProvider(picture.StorageType).GetUrl(picture.ThumbnailPath),
Path = storageService.GetUrl(picture.StorageType, picture.Path),
ThumbnailPath = storageService.GetUrl(picture.StorageType, picture.ThumbnailPath),
Description = picture.Description,
CreatedAt = picture.CreatedAt,
Tags = picture.Tags?.Select(t => t.Name).ToList() ?? new List<string>(),

View File

@@ -2,10 +2,10 @@ using Foxel.Services.Interface;
using COSXML;
using COSXML.Auth;
using COSXML.Model.Object;
using COSXML.Model.Bucket;
using COSXML.Transfer;
using COSXML.CosException;
using COSXML.Model.Tag;
using Foxel.Services.Attributes;
namespace Foxel.Services.StorageProvider;
@@ -20,7 +20,7 @@ public class CustomQCloudCredentialProvider : DefaultSessionQCloudCredentialProv
Refresh();
}
public override void Refresh()
public sealed override void Refresh()
{
try
{
@@ -29,8 +29,8 @@ public class CustomQCloudCredentialProvider : DefaultSessionQCloudCredentialProv
string tmpToken = _configService["Storage:CosStorageToken"];
long tmpStartTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
long tmpExpiredTime = tmpStartTime + 7200;
SetQCloudCredential(tmpSecretId, tmpSecretKey,
String.Format("{0};{1}", tmpStartTime, tmpExpiredTime), tmpToken);
SetQCloudCredential(tmpSecretId, tmpSecretKey,
$"{tmpStartTime};{tmpExpiredTime}", tmpToken);
}
catch (Exception ex)
{
@@ -39,11 +39,9 @@ public class CustomQCloudCredentialProvider : DefaultSessionQCloudCredentialProv
}
}
}
[StorageProvider(StorageType.Cos)]
public class CosStorageProvider : IStorageProvider
{
private readonly string _secretId;
private readonly string _secretKey;
private readonly string _bucketName;
private readonly string _region;
private readonly string _cdnUrl;
@@ -55,14 +53,12 @@ public class CosStorageProvider : IStorageProvider
public CosStorageProvider(IConfigService configService)
{
_configService = configService;
_secretId = configService["Storage:CosStorageSecretId"];
_secretKey = configService["Storage:CosStorageSecretKey"];
_bucketName = configService["Storage:CosStorageBucketName"];
_region = configService["Storage:CosStorageRegion"];
_cdnUrl = configService["Storage:CosStorageCdnUrl"] ?? string.Empty;
_cdnUrl = configService["Storage:CosStorageCdnUrl"];
// 检查桶是否为公开读取(从配置获取)
bool.TryParse(configService["Storage:CosStoragePublicRead"] ?? "false", out _isPublicRead);
bool.TryParse(configService["Storage:CosStoragePublicRead"], out _isPublicRead);
// 在构造函数中初始化客户端,作为单例使用
_cosXmlClient = CreateClient();
@@ -96,7 +92,7 @@ public class CosStorageProvider : IStorageProvider
string tempPath = Path.GetTempFileName();
try
{
using (var fileStream2 = new FileStream(tempPath, FileMode.Create))
await using (var fileStream2 = new FileStream(tempPath, FileMode.Create))
{
await fileStream.CopyToAsync(fileStream2);
}
@@ -105,7 +101,7 @@ public class CosStorageProvider : IStorageProvider
var transferManager = new TransferManager(_cosXmlClient, transferConfig);
var uploadTask = new COSXMLUploadTask(_bucketName, objectKey);
uploadTask.SetSrcPath(tempPath);
var result = await transferManager.UploadAsync(uploadTask);
await transferManager.UploadAsync(uploadTask);
return objectKey;
}
finally
@@ -216,7 +212,7 @@ public class CosStorageProvider : IStorageProvider
var transferConfig = new TransferConfig();
var transferManager = new TransferManager(_cosXmlClient, transferConfig);
var downloadTask = new COSXMLDownloadTask(_bucketName, storagePath, tempDir, fileName);
var result = await transferManager.DownloadAsync(downloadTask);
await transferManager.DownloadAsync(downloadTask);
return localFilePath;
}
catch (CosClientException clientEx)

View File

@@ -1,7 +1,9 @@
using Foxel.Services.Attributes;
using Foxel.Services.Interface;
namespace Foxel.Services.StorageProvider;
[StorageProvider(StorageType.Local)]
public class LocalStorageProvider(IConfigService config) : IStorageProvider
{
private readonly string _baseDirectory = Path.Combine(Directory.GetCurrentDirectory(), "Uploads");

View File

@@ -1,3 +1,4 @@
using Foxel.Services.Attributes;
using Foxel.Services.Interface;
using Amazon.S3;
using Amazon.S3.Model;
@@ -5,6 +6,7 @@ using Amazon.S3.Transfer;
namespace Foxel.Services.StorageProvider;
[StorageProvider(StorageType.S3)]
public class S3StorageProvider : IStorageProvider
{
private readonly string _accessKey;
@@ -13,7 +15,6 @@ public class S3StorageProvider : IStorageProvider
private readonly string _region;
private readonly string _endpoint;
private readonly bool _usePathStyleUrls;
private readonly string _serverUrl;
private readonly string _cdnUrl;
public S3StorageProvider(IConfigService configService)
@@ -22,9 +23,8 @@ public class S3StorageProvider : IStorageProvider
_secretKey = configService["Storage:S3StorageSecretKey"];
_bucketName = configService["Storage:S3StorageBucketName"];
_region = configService["Storage:S3StorageRegion"];
_serverUrl = configService["AppSettings:ServerUrl"];
_cdnUrl = configService["Storage:S3StorageCdnUrl"] ?? string.Empty;
_endpoint = configService["Storage:S3StorageEndpoint"] ?? $"https://s3.{_region}.amazonaws.com";
_cdnUrl = configService["Storage:S3StorageCdnUrl"];
_endpoint = configService["Storage:S3StorageEndpoint"];
_usePathStyleUrls = bool.TryParse(configService["Storage:S3StorageUsePathStyleUrls"], out var usePathStyle) && usePathStyle;
}
@@ -163,7 +163,7 @@ public class S3StorageProvider : IStorageProvider
};
using var response = await client.GetObjectAsync(request);
using var fileStream = new FileStream(tempFilePath, FileMode.Create);
await using var fileStream = new FileStream(tempFilePath, FileMode.Create);
await response.ResponseStream.CopyToAsync(fileStream);
return tempFilePath;

View File

@@ -1,3 +1,4 @@
using Foxel.Services.Attributes;
using Foxel.Services.Interface;
using System.Net.Http.Headers;
using System.Text.Json;
@@ -5,6 +6,7 @@ using System.Text.Json.Serialization;
namespace Foxel.Services.StorageProvider;
[StorageProvider(StorageType.Telegram)]
public class TelegramStorageProvider(IConfigService configService) : IStorageProvider
{
private readonly string _botToken = configService["Storage:TelegramStorageBotToken"];
@@ -83,7 +85,7 @@ public class TelegramStorageProvider(IConfigService configService) : IStoragePro
using var httpClient = new HttpClient();
var url =
$"https://api.telegram.org/bot{_botToken}/deleteMessage?chat_id={metadata.ChatId}&message_id={metadata.MessageId}";
var response = await httpClient.GetAsync(url);
await httpClient.GetAsync(url);
}
catch (Exception ex)
{
@@ -165,8 +167,8 @@ public class TelegramStorageProvider(IConfigService configService) : IStoragePro
string tempFilePath = Path.Combine(tempDir, tempFileName);
// 保存文件
using var fileStream = await fileResponse.Content.ReadAsStreamAsync();
using var outputStream = new FileStream(tempFilePath, FileMode.Create);
await using var fileStream = await fileResponse.Content.ReadAsStreamAsync();
await using var outputStream = new FileStream(tempFilePath, FileMode.Create);
await fileStream.CopyToAsync(outputStream);
return tempFilePath;

View File

@@ -1,25 +0,0 @@
using Foxel.Models.DataBase;
using Foxel.Services.Interface;
using Foxel.Services.StorageProvider;
using Pgvector.EntityFrameworkCore;
namespace Foxel.Services;
public class StorageProviderFactory(
LocalStorageProvider localStorageProvider,
TelegramStorageProvider telegramStorageProvider,
CosStorageProvider cosStorageProvider,
S3StorageProvider s3StorageProvider) : IStorageProviderFactory
{
public IStorageProvider GetProvider(StorageType storageType)
{
return storageType switch
{
StorageType.Local => localStorageProvider,
StorageType.Telegram => telegramStorageProvider,
StorageType.S3 => s3StorageProvider,
StorageType.Cos => cosStorageProvider,
_ => throw new ArgumentException($"不支持的存储类型: {storageType}")
};
}
}

106
Services/StorageService.cs Normal file
View File

@@ -0,0 +1,106 @@
using System.Reflection;
using Foxel.Models.DataBase;
using Foxel.Services.Attributes;
using Foxel.Services.Interface;
namespace Foxel.Services;
/// <summary>
/// 统一的存储服务实现
/// </summary>
public class StorageService : IStorageService
{
private readonly IServiceProvider _serviceProvider;
private readonly Dictionary<StorageType, Type> _storageProviders = new();
public StorageService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
RegisterStorageProviders();
}
/// <summary>
/// 使用反射扫描和注册所有标记了StorageProviderAttribute的存储提供者
/// </summary>
private void RegisterStorageProviders()
{
// 获取当前应用程序域中所有程序集
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (var assembly in assemblies)
{
try
{
// 扫描每个程序集中的所有类型
var types = assembly.GetTypes()
.Where(type => type is { IsClass: true, IsAbstract: false } &&
type.GetInterfaces().Contains(typeof(IStorageProvider)) &&
type.GetCustomAttribute<StorageProviderAttribute>() != null);
foreach (var type in types)
{
var attribute = type.GetCustomAttribute<StorageProviderAttribute>();
if (attribute != null)
{
// 注册存储提供者类型与对应的存储类型
_storageProviders[attribute.StorageType] = type;
}
}
}
catch (Exception ex)
{
Console.WriteLine($"扫描程序集 {assembly.FullName} 出错: {ex.Message}");
// 继续扫描其他程序集
}
}
}
/// <summary>
/// 获取指定存储类型的提供者实例
/// </summary>
public IStorageProvider GetProvider(StorageType storageType)
{
if (!_storageProviders.TryGetValue(storageType, out var providerType))
{
throw new ArgumentException($"未找到存储类型 {storageType} 的提供者");
}
return (IStorageProvider)_serviceProvider.GetRequiredService(providerType);
}
/// <summary>
/// 使用指定存储类型保存文件
/// </summary>
public Task<string> SaveAsync(StorageType storageType, Stream fileStream, string fileName, string contentType)
{
var provider = GetProvider(storageType);
return provider.SaveAsync(fileStream, fileName, contentType);
}
/// <summary>
/// 使用指定存储类型删除文件
/// </summary>
public Task DeleteAsync(StorageType storageType, string storagePath)
{
var provider = GetProvider(storageType);
return provider.DeleteAsync(storagePath);
}
/// <summary>
/// 使用指定存储类型获取文件URL
/// </summary>
public string GetUrl(StorageType storageType, string storagePath)
{
var provider = GetProvider(storageType);
return provider.GetUrl(storagePath);
}
/// <summary>
/// 使用指定存储类型下载文件
/// </summary>
public Task<string> DownloadFileAsync(StorageType storageType, string storagePath)
{
var provider = GetProvider(storageType);
return provider.DownloadFileAsync(storagePath);
}
}