feat(storage): implement storage management API and enhance storage mode handling

This commit is contained in:
shiyu
2025-06-09 12:12:15 +08:00
parent 4ef4b2056b
commit 0a6fe70537
43 changed files with 2449 additions and 907 deletions

View File

@@ -15,7 +15,7 @@ public interface IStorageProvider
/// <summary>
/// 获取文件URL
/// </summary>
string GetUrl(string storagePath);
string GetUrl(int pictureId,string storagePath);
/// <summary>
/// 下载文件到本地临时目录

View File

@@ -8,18 +8,18 @@ namespace Foxel.Services.Storage;
public interface IStorageService
{
/// <summary>
/// 在指定存储类型上执行操作
/// 在指定存储模式上执行操作
/// </summary>
/// <typeparam name="TResult">操作结果类型</typeparam>
/// <param name="storageType">存储类型</param>
/// <param name="storageModeId">存储模式的ID</param>
/// <param name="operation">要执行的操作</param>
/// <returns>操作结果</returns>
Task<TResult> ExecuteAsync<TResult>(StorageType storageType, Func<IStorageProvider, Task<TResult>> operation);
Task<TResult> ExecuteAsync<TResult>(int storageModeId, Func<IStorageProvider, Task<TResult>> operation);
/// <summary>
/// 在指定存储类型上执行无返回值的操作
/// 在指定存储模式上执行无返回值的操作
/// </summary>
/// <param name="storageType">存储类型</param>
/// <param name="storageModeId">存储模式的ID</param>
/// <param name="operation">要执行的操作</param>
Task ExecuteAsync(StorageType storageType, Func<IStorageProvider, Task> operation);
Task ExecuteAsync(int storageModeId, Func<IStorageProvider, Task> operation);
}

View File

@@ -10,15 +10,26 @@ using Microsoft.Extensions.Logging;
namespace Foxel.Services.Storage.Providers;
public class CosStorageConfig
{
public string Region { get; set; } = string.Empty;
public string SecretId { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
public string? Token { get; set; } // Token 可能为空
public string BucketName { get; set; } = string.Empty;
public string? CdnUrl { get; set; }
public bool PublicRead { get; set; } = false;
}
public class CustomQCloudCredentialProvider : DefaultSessionQCloudCredentialProvider
{
private readonly IConfigService _configService;
private readonly CosStorageConfig _config;
private readonly ILogger<CustomQCloudCredentialProvider> _logger;
public CustomQCloudCredentialProvider(IConfigService configService, ILogger<CustomQCloudCredentialProvider> logger)
: base(null, null, 0L, null)
public CustomQCloudCredentialProvider(CosStorageConfig config, ILogger<CustomQCloudCredentialProvider> logger)
: base(null, null, 0L, null) // Base constructor parameters are set in Refresh
{
_configService = configService;
_config = config;
_logger = logger;
Refresh();
}
@@ -27,11 +38,12 @@ public class CustomQCloudCredentialProvider : DefaultSessionQCloudCredentialProv
{
try
{
string tmpSecretId = _configService["Storage:CosStorageSecretId"];
string tmpSecretKey = _configService["Storage:CosStorageSecretKey"];
string tmpToken = _configService["Storage:CosStorageToken"];
string tmpSecretId = _config.SecretId;
string tmpSecretKey = _config.SecretKey;
string? tmpToken = _config.Token;
long tmpStartTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
long tmpExpiredTime = tmpStartTime + 7200;
// 腾讯云建议临时密钥有效期最长2小时7200秒)
long tmpExpiredTime = tmpStartTime + 7200;
SetQCloudCredential(tmpSecretId, tmpSecretKey,
$"{tmpStartTime};{tmpExpiredTime}", tmpToken);
}
@@ -44,18 +56,38 @@ public class CustomQCloudCredentialProvider : DefaultSessionQCloudCredentialProv
}
[StorageProvider(StorageType.Cos)]
public class CosStorageProvider(IConfigService configService, ILogger<CosStorageProvider> logger) : IStorageProvider
public class CosStorageProvider : IStorageProvider
{
private readonly CosStorageConfig _cosConfig;
private readonly IConfigService _configService; // 保留用于可能的应用级配置
private readonly ILogger<CosStorageProvider> _logger;
public CosStorageProvider(CosStorageConfig cosConfig, IConfigService configService, ILogger<CosStorageProvider> logger)
{
_cosConfig = cosConfig;
_configService = configService; // 存储起来以备后用
_logger = logger;
if (string.IsNullOrEmpty(_cosConfig.Region) ||
string.IsNullOrEmpty(_cosConfig.SecretId) ||
string.IsNullOrEmpty(_cosConfig.SecretKey) ||
string.IsNullOrEmpty(_cosConfig.BucketName))
{
_logger.LogError("COS Storage配置不完整 (Region, SecretId, SecretKey, BucketName 都是必需的).");
throw new InvalidOperationException("COS Storage配置不完整。");
}
}
private CosXml CreateClient()
{
var config = new CosXmlConfig.Builder()
.IsHttps(true)
.SetRegion(configService["Storage:CosStorageRegion"])
.SetRegion(_cosConfig.Region)
.SetDebugLog(true)
.Build();
var cosCredentialProvider = new CustomQCloudCredentialProvider(configService,
logger.IsEnabled(LogLevel.Debug) ?
var cosCredentialProvider = new CustomQCloudCredentialProvider(_cosConfig,
_logger.IsEnabled(LogLevel.Debug) ?
Microsoft.Extensions.Logging.LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger<CustomQCloudCredentialProvider>() :
Microsoft.Extensions.Logging.Abstractions.NullLogger<CustomQCloudCredentialProvider>.Instance);
@@ -85,7 +117,7 @@ public class CosStorageProvider(IConfigService configService, ILogger<CosStorage
var cosXmlClient = CreateClient();
var transferConfig = new TransferConfig();
var transferManager = new TransferManager(cosXmlClient, transferConfig);
var uploadTask = new COSXMLUploadTask(configService["Storage:CosStorageBucketName"], objectKey);
var uploadTask = new COSXMLUploadTask(_cosConfig.BucketName, objectKey);
uploadTask.SetSrcPath(tempPath);
await transferManager.UploadAsync(uploadTask);
return objectKey;
@@ -101,17 +133,17 @@ public class CosStorageProvider(IConfigService configService, ILogger<CosStorage
}
catch (CosClientException clientEx)
{
logger.LogError(clientEx, "COS客户端异常");
_logger.LogError(clientEx, "COS客户端异常");
throw;
}
catch (CosServerException serverEx)
{
logger.LogError(serverEx, "COS服务器异常: {ServerInfo}", serverEx.GetInfo());
_logger.LogError(serverEx, "COS服务器异常: {ServerInfo}", serverEx.GetInfo());
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "上传文件到腾讯云COS时出错");
_logger.LogError(ex, "上传文件到腾讯云COS时出错");
throw;
}
}
@@ -124,38 +156,38 @@ public class CosStorageProvider(IConfigService configService, ILogger<CosStorage
return;
var cosXmlClient = CreateClient();
var request = new DeleteObjectRequest(configService["Storage:CosStorageBucketName"], storagePath);
var request = new DeleteObjectRequest(_cosConfig.BucketName, storagePath);
await Task.Run(() => cosXmlClient.DeleteObject(request));
}
catch (CosClientException clientEx)
{
logger.LogWarning(clientEx, "COS客户端异常");
_logger.LogWarning(clientEx, "COS客户端异常");
}
catch (CosServerException serverEx)
{
logger.LogWarning(serverEx, "COS服务器异常: {ServerInfo}", serverEx.GetInfo());
_logger.LogWarning(serverEx, "COS服务器异常: {ServerInfo}", serverEx.GetInfo());
}
catch (Exception ex)
{
logger.LogWarning(ex, "从腾讯云COS删除文件时出错");
_logger.LogWarning(ex, "从腾讯云COS删除文件时出错");
}
}
public string GetUrl(string storagePath)
public string GetUrl(int pictureId,string storagePath)
{
try
{
if (string.IsNullOrEmpty(storagePath))
return "/images/unavailable.gif";
string cdnUrl = configService["Storage:CosStorageCdnUrl"];
string bucketName = configService["Storage:CosStorageBucketName"];
string region = configService["Storage:CosStorageRegion"];
bool isPublicRead = bool.TryParse(configService["Storage:CosStoragePublicRead"], out var publicRead) && publicRead;
string? cdnUrl = _cosConfig.CdnUrl;
string bucketName = _cosConfig.BucketName;
string region = _cosConfig.Region;
bool isPublicRead = _cosConfig.PublicRead;
// 优先使用CDN
if (!string.IsNullOrEmpty(cdnUrl))
return $"{cdnUrl}/{storagePath}";
return $"{cdnUrl.TrimEnd('/')}/{storagePath}";
// 公开读取的桶可直接访问
if (isPublicRead)
@@ -179,7 +211,7 @@ public class CosStorageProvider(IConfigService configService, ILogger<CosStorage
}
catch (Exception ex)
{
logger.LogError(ex, "生成腾讯云COS文件URL时出错");
_logger.LogError(ex, "生成腾讯云COS文件URL时出错");
return "/images/unavailable.gif";
}
}
@@ -200,7 +232,7 @@ public class CosStorageProvider(IConfigService configService, ILogger<CosStorage
Directory.CreateDirectory(tempDir);
}
string bucketName = configService["Storage:CosStorageBucketName"];
string bucketName = _cosConfig.BucketName;
string fileName = Path.GetFileName(storagePath);
var cosXmlClient = CreateClient();
@@ -212,17 +244,17 @@ public class CosStorageProvider(IConfigService configService, ILogger<CosStorage
}
catch (CosClientException clientEx)
{
logger.LogError(clientEx, "COS客户端异常");
_logger.LogError(clientEx, "COS客户端异常");
throw;
}
catch (CosServerException serverEx)
{
logger.LogError(serverEx, "COS服务器异常: {ServerInfo}", serverEx.GetInfo());
_logger.LogError(serverEx, "COS服务器异常: {ServerInfo}", serverEx.GetInfo());
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "从腾讯云COS下载文件时出错");
_logger.LogError(ex, "从腾讯云COS下载文件时出错");
throw;
}
}

View File

@@ -4,49 +4,134 @@ using Microsoft.Extensions.Logging;
namespace Foxel.Services.Storage.Providers;
[StorageProvider(StorageType.Local)]
public class LocalStorageProvider(IConfigService configService) : IStorageProvider
public class LocalStorageConfig
{
private readonly string _baseDirectory = Path.Combine(Directory.GetCurrentDirectory(), "Uploads");
public string BasePath { get; set; } = string.Empty;
public string ServerUrl { get; set; } = string.Empty;
public string PublicBasePath { get; set; } = "/Uploads";
}
[StorageProvider(StorageType.Local)]
public class LocalStorageProvider : IStorageProvider
{
private readonly LocalStorageConfig _config;
private readonly ILogger<LocalStorageProvider> _logger;
public LocalStorageProvider(LocalStorageConfig config, ILogger<LocalStorageProvider> logger)
{
_config = config;
_logger = logger;
if (string.IsNullOrWhiteSpace(_config.BasePath))
{
var defaultPath = Path.Combine(Directory.GetCurrentDirectory(), "DefaultUploads");
_logger.LogWarning("LocalStorageConfig.BasePath 未配置,将使用默认路径: {DefaultPath}", defaultPath);
throw new InvalidOperationException("LocalStorageConfig.BasePath 必须在配置中提供。");
}
Directory.CreateDirectory(_config.BasePath);
}
public async Task<string> SaveAsync(Stream fileStream, string fileName, string contentType)
{
string currentDate = DateTime.Now.ToString("yyyy/MM");
string folder = Path.Combine(_baseDirectory, currentDate);
Directory.CreateDirectory(folder);
try
{
string currentDate = DateTime.Now.ToString("yyyy/MM");
string folder = Path.Combine(_config.BasePath, currentDate);
Directory.CreateDirectory(folder);
string newFileName = fileName;
string filePath = Path.Combine(folder, newFileName);
string newFileName = fileName;
string filePath = Path.Combine(folder, newFileName);
await using var output = new FileStream(filePath, FileMode.Create);
await fileStream.CopyToAsync(output);
return $"/Uploads/{currentDate}/{newFileName}";
await using var output = new FileStream(filePath, FileMode.Create);
await fileStream.CopyToAsync(output);
return $"{_config.PublicBasePath.TrimEnd('/')}/{currentDate}/{newFileName}";
}
catch (Exception ex)
{
_logger.LogError(ex, "保存文件到本地存储时出错。BasePath: {BasePath}, FileName: {FileName}", _config.BasePath, fileName);
throw;
}
}
public Task DeleteAsync(string storagePath)
{
string fullPath = Path.Combine(Directory.GetCurrentDirectory(), storagePath.TrimStart('/'));
if (File.Exists(fullPath))
File.Delete(fullPath);
try
{
string relativePath = storagePath;
if (!string.IsNullOrEmpty(_config.PublicBasePath) && storagePath.StartsWith(_config.PublicBasePath))
{
relativePath = storagePath.Substring(_config.PublicBasePath.Length);
}
string fullPath = Path.Combine(_config.BasePath, relativePath.TrimStart('/'));
if (File.Exists(fullPath))
{
File.Delete(fullPath);
_logger.LogInformation("已删除本地文件: {FullPath}", fullPath);
}
else
{
_logger.LogWarning("尝试删除本地文件但文件未找到: {FullPath}", fullPath);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "删除本地文件时出错。StoragePath: {StoragePath}, BasePath: {BasePath}", storagePath,
_config.BasePath);
}
return Task.CompletedTask;
}
public string GetUrl(string? storagePath)
public string GetUrl(int pictureId,string? storagePath)
{
if (string.IsNullOrEmpty(storagePath))
return $"/images/unavailable.gif";
string serverUrl = configService["AppSettings:ServerUrl"];
return $"{serverUrl}{storagePath}";
string serverUrl = _config.ServerUrl.TrimEnd('/');
return string.IsNullOrEmpty(serverUrl) ? storagePath : $"{serverUrl}{storagePath}";
}
public Task<string> DownloadFileAsync(string storagePath)
{
string fullPath = Path.Combine(Directory.GetCurrentDirectory(), storagePath.TrimStart('/'));
if (!File.Exists(fullPath))
try
{
throw new FileNotFoundException($"找不到文件: {fullPath}");
string relativePath = storagePath;
if (!string.IsNullOrEmpty(_config.PublicBasePath) && storagePath.StartsWith(_config.PublicBasePath))
{
relativePath = storagePath.Substring(_config.PublicBasePath.Length);
}
string fullPath = Path.Combine(_config.BasePath, relativePath.TrimStart('/'));
if (!File.Exists(fullPath))
{
_logger.LogError("尝试下载但文件未找到: {FullPath}", fullPath);
throw new FileNotFoundException($"本地存储中找不到文件: {fullPath}", fullPath);
}
string tempFileName = Path.GetRandomFileName();
if (Path.HasExtension(fullPath))
{
tempFileName = Path.ChangeExtension(tempFileName, Path.GetExtension(fullPath));
}
string tempFilePath = Path.Combine(Path.GetTempPath(), tempFileName);
File.Copy(fullPath, tempFilePath, true);
_logger.LogInformation("已将文件 {FullPath} 复制到临时位置 {TempFilePath} 以供下载/处理", fullPath, tempFilePath);
return Task.FromResult(tempFilePath); // 返回临时文件的路径
}
catch (Exception ex)
{
_logger.LogError(ex, "下载本地文件时出错。StoragePath: {StoragePath}, BasePath: {BasePath}", storagePath,
_config.BasePath);
throw;
}
return Task.FromResult(fullPath);
}
}

View File

@@ -7,16 +7,47 @@ using Microsoft.Extensions.Logging;
namespace Foxel.Services.Storage.Providers;
[StorageProvider(StorageType.S3)]
public class S3StorageProvider(IConfigService configService, ILogger<S3StorageProvider> logger) : IStorageProvider
public class S3StorageConfig
{
public string AccessKey { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
public string Endpoint { get; set; } = string.Empty;
public string? Region { get; set; } // Region 可能为空特别是对于非AWS S3兼容存储
public bool UsePathStyleUrls { get; set; } = false;
public string BucketName { get; set; } = string.Empty;
public string? CdnUrl { get; set; }
}
[StorageProvider(StorageType.S3)]
public class S3StorageProvider : IStorageProvider
{
private readonly S3StorageConfig _s3Config;
private readonly IConfigService _configService; // 保留用于可能的应用级配置
private readonly ILogger<S3StorageProvider> _logger;
public S3StorageProvider(S3StorageConfig s3Config, IConfigService configService, ILogger<S3StorageProvider> logger)
{
_s3Config = s3Config;
_configService = configService;
_logger = logger;
if (string.IsNullOrEmpty(_s3Config.AccessKey) ||
string.IsNullOrEmpty(_s3Config.SecretKey) ||
string.IsNullOrEmpty(_s3Config.Endpoint) ||
string.IsNullOrEmpty(_s3Config.BucketName))
{
_logger.LogError("S3 Storage配置不完整 (AccessKey, SecretKey, Endpoint, BucketName 都是必需的).");
throw new InvalidOperationException("S3 Storage配置不完整。");
}
}
private AmazonS3Client CreateClient()
{
string accessKey = configService["Storage:S3StorageAccessKey"];
string secretKey = configService["Storage:S3StorageSecretKey"];
string endpoint = configService["Storage:S3StorageEndpoint"];
string region = configService["Storage:S3StorageRegion"];
bool usePathStyleUrls = bool.TryParse(configService["Storage:S3StorageUsePathStyleUrls"], out var usePathStyle) && usePathStyle;
string accessKey = _s3Config.AccessKey;
string secretKey = _s3Config.SecretKey;
string endpoint = _s3Config.Endpoint;
string? region = _s3Config.Region;
bool usePathStyleUrls = _s3Config.UsePathStyleUrls;
var config = new AmazonS3Config
{
@@ -25,7 +56,7 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
ForcePathStyle = usePathStyleUrls
};
if (!string.IsNullOrEmpty(region) && endpoint.Contains("amazonaws.com"))
if (!string.IsNullOrEmpty(region) && endpoint.Contains("amazonaws.com", StringComparison.OrdinalIgnoreCase))
{
config.RegionEndpoint = Amazon.RegionEndpoint.GetBySystemName(region);
}
@@ -55,7 +86,7 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
{
InputStream = fileStream,
Key = objectKey,
BucketName = configService["Storage:S3StorageBucketName"],
BucketName = _s3Config.BucketName,
ContentType = contentType
};
@@ -66,7 +97,7 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
}
catch (Exception ex)
{
logger.LogError(ex, "上传文件到S3时出错");
_logger.LogError(ex, "上传文件到S3时出错");
throw;
}
}
@@ -81,7 +112,7 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
using var client = CreateClient();
var deleteRequest = new DeleteObjectRequest
{
BucketName = configService["Storage:S3StorageBucketName"],
BucketName = _s3Config.BucketName,
Key = storagePath
};
@@ -89,30 +120,30 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
}
catch (Exception ex)
{
logger.LogWarning(ex, "从S3删除文件时出错");
_logger.LogWarning(ex, "从S3删除文件时出错");
}
}
public string GetUrl(string storagePath)
public string GetUrl(int pictureId,string storagePath)
{
try
{
if (string.IsNullOrEmpty(storagePath))
return "/images/unavailable.gif";
string cdnUrl = configService["Storage:S3StorageCdnUrl"];
string? cdnUrl = _s3Config.CdnUrl;
// 如果配置了CDN URL使用CDN
if (!string.IsNullOrEmpty(cdnUrl))
{
return $"{cdnUrl}/{storagePath}";
return $"{cdnUrl.TrimEnd('/')}/{storagePath}";
}
// 否则使用S3直链或生成预签名URL
using var client = CreateClient();
var request = new GetPreSignedUrlRequest
{
BucketName = configService["Storage:S3StorageBucketName"],
BucketName = _s3Config.BucketName,
Key = storagePath,
Expires = DateTime.UtcNow.AddHours(1) // URL有效期1小时
};
@@ -121,7 +152,7 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
}
catch (Exception ex)
{
logger.LogError(ex, "生成S3文件URL时出错");
_logger.LogError(ex, "生成S3文件URL时出错");
return "/images/unavailable.gif";
}
}
@@ -150,7 +181,7 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
using var client = CreateClient();
var request = new GetObjectRequest
{
BucketName = configService["Storage:S3StorageBucketName"],
BucketName = _s3Config.BucketName,
Key = storagePath
};
@@ -162,7 +193,7 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
}
catch (Exception ex)
{
logger.LogError(ex, "从S3下载文件时出错");
_logger.LogError(ex, "从S3下载文件时出错");
throw;
}
}

View File

@@ -4,21 +4,37 @@ using System.Text.Json.Serialization;
using Foxel.Services.Attributes;
using Foxel.Services.Configuration;
using System.Net;
using Microsoft.Extensions.Logging;
namespace Foxel.Services.Storage.Providers;
public class TelegramStorageConfig
{
public string BotToken { get; set; } = string.Empty;
public string ChatId { get; set; } = string.Empty;
public string? ProxyAddress { get; set; }
public string? ProxyPort { get; set; }
public string? ProxyUsername { get; set; }
public string? ProxyPassword { get; set; }
}
[StorageProvider(StorageType.Telegram)]
public class TelegramStorageProvider(IConfigService configService, ILogger<TelegramStorageProvider> logger) : IStorageProvider
public class TelegramStorageProvider(TelegramStorageConfig _telegramConfig, IConfigService configService, ILogger<TelegramStorageProvider> logger) : IStorageProvider
{
public async Task<string> SaveAsync(Stream fileStream, string fileName, string contentType)
{
string botToken = configService["Storage:TelegramStorageBotToken"];
string chatId = configService["Storage:TelegramStorageChatId"];
string botToken = _telegramConfig.BotToken;
string chatId = _telegramConfig.ChatId;
if (string.IsNullOrEmpty(botToken) || string.IsNullOrEmpty(chatId))
{
logger.LogError("Telegram BotToken 或 ChatId 未在配置中提供。");
throw new InvalidOperationException("Telegram BotToken 或 ChatId 未配置。");
}
using var httpClient = CreateHttpClient();
using var formData = new MultipartFormDataContent();
formData.Add(new StringContent(chatId), "chat_id");
using var formData = new MultipartFormDataContent
{
{ new StringContent(chatId), "chat_id" }
};
var safeFileName = Path.GetFileNameWithoutExtension(fileName);
if (safeFileName.Length > 100)
safeFileName = safeFileName.Substring(0, 100);
@@ -103,23 +119,37 @@ public class TelegramStorageProvider(IConfigService configService, ILogger<Teleg
var metadata = JsonSerializer.Deserialize<TelegramFileMetadata>(storagePath);
if (metadata == null || string.IsNullOrEmpty(metadata.ChatId) || metadata.MessageId <= 0)
{
logger.LogWarning("无效的 Telegram 元数据,无法删除: {StoragePath}", storagePath);
return;
}
string botToken = configService["Storage:TelegramStorageBotToken"];
string botToken = _telegramConfig.BotToken;
if (string.IsNullOrEmpty(botToken))
{
logger.LogError("Telegram BotToken 未在配置中提供,无法删除文件。");
return;
}
using var httpClient = CreateHttpClient();
var url =
$"https://api.telegram.org/bot{botToken}/deleteMessage?chat_id={metadata.ChatId}&message_id={metadata.MessageId}";
await httpClient.GetAsync(url);
var response = await httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
logger.LogWarning("删除 Telegram 消息失败: ChatId={ChatId}, MessageId={MessageId}, Status={StatusCode}, Response={ErrorContent}", metadata.ChatId, metadata.MessageId, response.StatusCode, errorContent);
}
}
catch (JsonException jsonEx)
{
logger.LogWarning(jsonEx, "解析 Telegram 元数据以进行删除时出错: {StoragePath}", storagePath);
}
catch (Exception ex)
{
logger.LogWarning(ex, "删除 Telegram 文件时出错");
logger.LogWarning(ex, "删除 Telegram 文件时出错: {StoragePath}", storagePath);
}
}
public string GetUrl(string storagePath)
public string GetUrl(int pictureId,string storagePath)
{
try
{
@@ -130,12 +160,17 @@ public class TelegramStorageProvider(IConfigService configService, ILogger<Teleg
}
string serverUrl = configService["AppSettings:ServerUrl"];
return $"{serverUrl}/api/picture/get_telegram_file?fileId={metadata.FileId}";
return $"{serverUrl}/api/picture/file/{pictureId}";
}
catch (JsonException jsonEx)
{
logger.LogError(jsonEx, "解析 Telegram 元数据以生成 URL 时出错: {StoragePath}", storagePath);
return "/images/unavailable.gif";
}
catch (Exception ex)
{
logger.LogError(ex, "生成 Telegram 文件 URL 时出错");
return $"/images/unavailable.gif";
logger.LogError(ex, "生成 Telegram 文件 URL 时出错: {StoragePath}", storagePath);
return "/images/unavailable.gif";
}
}
@@ -154,7 +189,12 @@ public class TelegramStorageProvider(IConfigService configService, ILogger<Teleg
throw new ApplicationException("无效的存储路径或元数据");
}
string botToken = configService["Storage:TelegramStorageBotToken"];
string botToken = _telegramConfig.BotToken;
if (string.IsNullOrEmpty(botToken))
{
logger.LogError("Telegram BotToken 未在配置中提供,无法下载文件。");
throw new InvalidOperationException("Telegram BotToken 未配置。");
}
using var httpClient = CreateHttpClient();
var getFileUrl = $"https://api.telegram.org/bot{botToken}/getFile?file_id={metadata.FileId}";
@@ -218,10 +258,10 @@ public class TelegramStorageProvider(IConfigService configService, ILogger<Teleg
HttpClient client;
// 检查是否有代理配置
string proxyAddress = configService["Storage:TelegramProxyAddress"];
string proxyPort = configService["Storage:TelegramProxyPort"];
string proxyUsername = configService["Storage:TelegramProxyUsername"];
string proxyPassword = configService["Storage:TelegramProxyPassword"];
string? proxyAddress = _telegramConfig.ProxyAddress;
string? proxyPort = _telegramConfig.ProxyPort;
string? proxyUsername = _telegramConfig.ProxyUsername;
string? proxyPassword = _telegramConfig.ProxyPassword;
if (!string.IsNullOrEmpty(proxyAddress) && !string.IsNullOrEmpty(proxyPort) && int.TryParse(proxyPort, out int port))
{

View File

@@ -2,18 +2,43 @@ using System.Net.Http.Headers;
using System.Text;
using Foxel.Services.Attributes;
using Foxel.Services.Configuration;
using Microsoft.Extensions.Logging;
namespace Foxel.Services.Storage.Providers;
[StorageProvider(StorageType.WebDAV)]
public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavStorageProvider> logger) : IStorageProvider
public class WebDavStorageConfig
{
public string ServerUrl { get; set; } = string.Empty;
public string BasePath { get; set; } = string.Empty;
public string? UserName { get; set; }
public string? Password { get; set; }
public string? PublicUrl { get; set; }
}
[StorageProvider(StorageType.WebDAV)]
public class WebDavStorageProvider : IStorageProvider
{
private readonly WebDavStorageConfig _webDavConfig;
private readonly IConfigService _configService;
private readonly ILogger<WebDavStorageProvider> _logger;
public WebDavStorageProvider(WebDavStorageConfig webDavConfig, IConfigService configService, ILogger<WebDavStorageProvider> logger)
{
_webDavConfig = webDavConfig;
_configService = configService;
_logger = logger;
if (string.IsNullOrEmpty(_webDavConfig.ServerUrl))
{
_logger.LogError("WebDAV Storage配置不完整 (ServerUrl 是必需的).");
throw new InvalidOperationException("WebDAV Storage配置不完整。");
}
}
private HttpClient CreateClient()
{
var httpClient = new HttpClient();
var userName = configService["Storage:WebDAVUserName"];
var password = configService["Storage:WebDAVPassword"];
var userName = _webDavConfig.UserName;
var password = _webDavConfig.Password;
if (!string.IsNullOrEmpty(userName) && !string.IsNullOrEmpty(password))
{
@@ -28,8 +53,8 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
{
try
{
string webDavServerUrl = configService["Storage:WebDAVServerUrl"].TrimEnd('/');
string basePath = configService["Storage:WebDAVBasePath"].Trim('/');
string webDavServerUrl = _webDavConfig.ServerUrl.TrimEnd('/');
string basePath = _webDavConfig.BasePath?.Trim('/') ?? string.Empty;
// 创建唯一的文件存储路径
string currentDate = DateTime.Now.ToString("yyyy/MM");
@@ -58,7 +83,7 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
}
catch (Exception ex)
{
logger.LogError(ex, "上传文件到WebDAV时出错");
_logger.LogError(ex, "上传文件到WebDAV时出错");
throw;
}
}
@@ -70,7 +95,7 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
if (string.IsNullOrEmpty(storagePath))
return;
string webDavServerUrl = configService["Storage:WebDAVServerUrl"].TrimEnd('/');
string webDavServerUrl = _webDavConfig.ServerUrl.TrimEnd('/');
var requestUri = $"{webDavServerUrl}/{storagePath}";
using var client = CreateClient();
@@ -83,30 +108,30 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
}
catch (Exception ex)
{
logger.LogWarning(ex, "从WebDAV删除文件时出错");
_logger.LogWarning(ex, "从WebDAV删除文件时出错");
}
}
public string GetUrl(string storagePath)
public string GetUrl(int pictureId,string storagePath)
{
try
{
if (string.IsNullOrEmpty(storagePath))
return "/images/unavailable.gif";
string publicUrl = configService["Storage:WebDAVPublicUrl"].TrimEnd('/');
string serverUrl = configService["AppSettings:ServerUrl"];
string? publicUrl = _webDavConfig.PublicUrl?.TrimEnd('/');
string serverUrl = _configService["AppSettings:ServerUrl"];
if (!string.IsNullOrEmpty(publicUrl))
{
return $"{publicUrl}/{storagePath}";
}
return $"{serverUrl}/api/picture/proxy?path={Uri.EscapeDataString(storagePath)}";
return $"{serverUrl}/api/picture/file/{pictureId}";
}
catch (Exception ex)
{
logger.LogError(ex, "生成WebDAV文件URL时出错");
_logger.LogError(ex, "生成WebDAV文件URL时出错");
return "/images/unavailable.gif";
}
}
@@ -120,7 +145,7 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
throw new ArgumentException("存储路径不能为空");
}
string webDavServerUrl = configService["Storage:WebDAVServerUrl"].TrimEnd('/');
string webDavServerUrl = _webDavConfig.ServerUrl.TrimEnd('/');
// 创建临时目录
var tempDir = Path.Combine(Path.GetTempPath(), "FoxelWebDAVTemp");
@@ -147,7 +172,7 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
}
catch (Exception ex)
{
logger.LogError(ex, "从WebDAV下载文件时出错");
_logger.LogError(ex, "从WebDAV下载文件时出错");
throw;
}
}
@@ -159,7 +184,7 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
{
try
{
string webDavServerUrl = configService["Storage:WebDAVServerUrl"].TrimEnd('/');
string webDavServerUrl = _webDavConfig.ServerUrl.TrimEnd('/');
var requestUri = $"{webDavServerUrl}/{directoryPath}";
using var client = CreateClient();
@@ -213,7 +238,7 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
}
catch (Exception ex)
{
logger.LogError(ex, "确保WebDAV目录存在时出错");
_logger.LogError(ex, "确保WebDAV目录存在时出错");
throw;
}
}

View File

@@ -1,6 +1,8 @@
using System.Reflection;
using Foxel.Services.Attributes;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore; // For IDbContextFactory
using System.Text.Json; // For JsonSerializer
using Foxel.Services.Storage.Providers; // For specific config classes
namespace Foxel.Services.Storage;
@@ -12,11 +14,16 @@ public class StorageService : IStorageService
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<StorageService> _logger;
private readonly Dictionary<StorageType, Type> _storageProviders = new();
private readonly IDbContextFactory<MyDbContext> _contextFactory;
public StorageService(IServiceProvider serviceProvider, ILogger<StorageService> logger)
public StorageService(
IServiceProvider serviceProvider,
ILogger<StorageService> logger,
IDbContextFactory<MyDbContext> contextFactory)
{
_serviceProvider = serviceProvider;
_logger = logger;
_contextFactory = contextFactory;
RegisterStorageProviders();
}
@@ -27,14 +34,14 @@ public class StorageService : IStorageService
{
// 获取当前应用程序域中所有程序集
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (var assembly in assemblies)
{
try
{
// 扫描每个程序集中的所有类型
var types = assembly.GetTypes()
.Where(type => type is { IsClass: true, IsAbstract: false } &&
.Where(type => type is { IsClass: true, IsAbstract: false } &&
type.GetInterfaces().Contains(typeof(IStorageProvider)) &&
type.GetCustomAttribute<StorageProviderAttribute>() != null);
@@ -45,45 +52,123 @@ public class StorageService : IStorageService
{
// 注册存储提供者类型与对应的存储类型
_storageProviders[attribute.StorageType] = type;
_logger.LogInformation("已注册存储提供者: {StorageType} -> {ProviderType}", attribute.StorageType, type.FullName);
}
}
}
catch (ReflectionTypeLoadException ex) // 更具体地捕获加载类型时的异常
{
_logger.LogWarning(ex, "扫描程序集 {AssemblyName} 时发生类型加载错误。详细信息: {LoaderExceptions}", assembly.FullName,
string.Join(", ", ex.LoaderExceptions.Select(e => e?.Message ?? "N/A")));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "扫描程序集 {AssemblyName} 时发生错误", assembly.FullName);
// 继续扫描其他程序集
}
}
if (!_storageProviders.Any())
{
_logger.LogWarning("未能注册任何存储提供者。请检查提供者是否正确标记了 [StorageProvider] 特性并且位于扫描的程序集中。");
}
}
/// <summary>
/// 获取指定存储类型的提供者实例
/// 根据 StorageModeId 获取并配置提供者实例
/// </summary>
private IStorageProvider GetProvider(StorageType storageType)
private IStorageProvider GetProvider(int storageModeId)
{
if (!_storageProviders.TryGetValue(storageType, out var providerType))
using var context = _contextFactory.CreateDbContext();
var storageMode = context.StorageModes
.AsNoTracking()
.FirstOrDefault(sm => sm.Id == storageModeId);
if (storageMode == null)
{
throw new ArgumentException($"未找到存储类型 {storageType} 的提供者");
_logger.LogError("ID 为 {StorageModeId} 的 StorageMode 未找到。", storageModeId);
throw new ArgumentException($"ID 为 {storageModeId} 的 StorageMode 未找到。");
}
return (IStorageProvider)_serviceProvider.GetRequiredService(providerType);
if (!storageMode.IsEnabled)
{
_logger.LogWarning("StorageMode {StorageModeId} ({StorageModeName}) 未启用。", storageModeId, storageMode.Name);
throw new InvalidOperationException($"StorageMode '{storageMode.Name}' (ID: {storageModeId}) 未启用。");
}
if (!_storageProviders.TryGetValue(storageMode.StorageType, out var providerType))
{
_logger.LogError("未找到 StorageType {StorageType} (来自 StorageMode {StorageModeId}) 的已注册提供者。", storageMode.StorageType, storageModeId);
throw new ArgumentException($"未找到 StorageType {storageMode.StorageType} 的提供者。");
}
object specificConfig = DeserializeProviderConfig(storageMode.StorageType, storageMode.ConfigurationJson, storageMode.Name);
try
{
return (IStorageProvider)ActivatorUtilities.CreateInstance(_serviceProvider, providerType, specificConfig);
}
catch (Exception ex)
{
_logger.LogError(ex, "为 StorageMode {StorageModeName} (ID: {StorageModeId}, Type: {StorageType}) 创建提供者 {ProviderType} 的实例失败。",
storageMode.Name, storageModeId, storageMode.StorageType, providerType.FullName);
throw new InvalidOperationException($"创建存储提供者 '{providerType.Name}' 失败: {ex.Message}", ex);
}
}
private object DeserializeProviderConfig(StorageType storageType, string? jsonConfig, string storageModeName)
{
if (string.IsNullOrWhiteSpace(jsonConfig))
{
_logger.LogError("StorageMode '{StorageModeName}' (Type: {StorageType}) 的 ConfigurationJson 为空或空白。", storageModeName, storageType);
throw new InvalidOperationException($"StorageMode '{storageModeName}' (Type: {storageType}) 的配置 (ConfigurationJson) 为空。");
}
try
{
switch (storageType)
{
case StorageType.Local:
return JsonSerializer.Deserialize<LocalStorageConfig>(jsonConfig)
?? throw new JsonException($"无法反序列化 LocalStorageConfig。JSON: {jsonConfig}");
case StorageType.Telegram:
return JsonSerializer.Deserialize<TelegramStorageConfig>(jsonConfig)
?? throw new JsonException($"无法反序列化 TelegramStorageConfig。JSON: {jsonConfig}");
case StorageType.S3:
return JsonSerializer.Deserialize<S3StorageConfig>(jsonConfig)
?? throw new JsonException($"无法反序列化 S3StorageConfig。JSON: {jsonConfig}");
case StorageType.Cos:
return JsonSerializer.Deserialize<CosStorageConfig>(jsonConfig)
?? throw new JsonException($"无法反序列化 CosStorageConfig。JSON: {jsonConfig}");
case StorageType.WebDAV:
return JsonSerializer.Deserialize<WebDavStorageConfig>(jsonConfig)
?? throw new JsonException($"无法反序列化 WebDavStorageConfig。JSON: {jsonConfig}");
default:
_logger.LogError("不支持的存储类型配置反序列化: {StorageType} (来自 StorageMode '{StorageModeName}')", storageType, storageModeName);
throw new NotSupportedException($"不支持 StorageType {storageType} 的配置反序列化。");
}
}
catch (JsonException ex)
{
_logger.LogError(ex, "反序列化 StorageMode '{StorageModeName}' (Type: {StorageType}) 的配置失败。JSON: {JsonConfig}", storageModeName, storageType, jsonConfig);
throw new InvalidOperationException($"StorageMode '{storageModeName}' (Type: {storageType}) 的配置格式无效。", ex);
}
}
/// <summary>
/// 在指定存储类型上执行操作
/// 在指定存储模式上执行操作
/// </summary>
public async Task<TResult> ExecuteAsync<TResult>(StorageType storageType, Func<IStorageProvider, Task<TResult>> operation)
public async Task<TResult> ExecuteAsync<TResult>(int storageModeId, Func<IStorageProvider, Task<TResult>> operation)
{
var provider = GetProvider(storageType);
var provider = GetProvider(storageModeId);
return await operation(provider);
}
/// <summary>
/// 在指定存储类型上执行无返回值的操作
/// 在指定存储模式上执行无返回值的操作
/// </summary>
public async Task ExecuteAsync(StorageType storageType, Func<IStorageProvider, Task> operation)
public async Task ExecuteAsync(int storageModeId, Func<IStorageProvider, Task> operation)
{
var provider = GetProvider(storageType);
var provider = GetProvider(storageModeId);
await operation(provider);
}
}
}