From df2358d6882341e0d67f4c42cbf1d7eca9bc71de Mon Sep 17 00:00:00 2001 From: ShiYu Date: Wed, 21 May 2025 23:49:05 +0800 Subject: [PATCH] feat(storage): add support for Tencent Cloud COS --- Extensions/ServiceCollectionExtensions.cs | 1 + Foxel.csproj | 1 + Models/DataBase/Picture.cs | 1 + .../StorageProvider/CosStorageProvider.cs | 238 ++++++++++++++++++ Services/StorageProviderFactory.cs | 3 + View/src/config/routeConfig.tsx | 11 - View/src/pages/settings/SystemConfig.tsx | 29 ++- View/src/pages/upload/Index.tsx | 23 -- 8 files changed, 271 insertions(+), 36 deletions(-) create mode 100644 Services/StorageProvider/CosStorageProvider.cs delete mode 100644 View/src/pages/upload/Index.tsx diff --git a/Extensions/ServiceCollectionExtensions.cs b/Extensions/ServiceCollectionExtensions.cs index 8e34997..353203c 100644 --- a/Extensions/ServiceCollectionExtensions.cs +++ b/Extensions/ServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); } diff --git a/Foxel.csproj b/Foxel.csproj index 42cfa59..a479dbd 100644 --- a/Foxel.csproj +++ b/Foxel.csproj @@ -21,6 +21,7 @@ + diff --git a/Models/DataBase/Picture.cs b/Models/DataBase/Picture.cs index fbc8db7..63ec599 100644 --- a/Models/DataBase/Picture.cs +++ b/Models/DataBase/Picture.cs @@ -10,6 +10,7 @@ public enum StorageType Local = 0, Telegram = 1, S3 = 2, + Cos = 3, } public class Picture : BaseModel diff --git a/Services/StorageProvider/CosStorageProvider.cs b/Services/StorageProvider/CosStorageProvider.cs new file mode 100644 index 0000000..49883f9 --- /dev/null +++ b/Services/StorageProvider/CosStorageProvider.cs @@ -0,0 +1,238 @@ +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; + +namespace Foxel.Services.StorageProvider; + +public class CustomQCloudCredentialProvider : DefaultSessionQCloudCredentialProvider +{ + private readonly IConfigService _configService; + + public CustomQCloudCredentialProvider(IConfigService configService) + : base(null, null, 0L, null) + { + _configService = configService; + Refresh(); + } + + public override void Refresh() + { + try + { + string tmpSecretId = _configService["Storage:CosStorageSecretId"]; + string tmpSecretKey = _configService["Storage:CosStorageSecretKey"]; + string tmpToken = _configService["Storage:CosStorageToken"]; + long tmpStartTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + long tmpExpiredTime = tmpStartTime + 7200; + SetQCloudCredential(tmpSecretId, tmpSecretKey, + String.Format("{0};{1}", tmpStartTime, tmpExpiredTime), tmpToken); + } + catch (Exception ex) + { + Console.WriteLine($"刷新临时密钥时出错: {ex.Message}"); + throw; + } + } +} + +public class CosStorageProvider : IStorageProvider +{ + private readonly string _secretId; + private readonly string _secretKey; + private readonly string _bucketName; + private readonly string _region; + private readonly string _cdnUrl; + private readonly IConfigService _configService; + private readonly CosXml _cosXmlClient; + + private readonly bool _isPublicRead; + + 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; + + // 检查桶是否为公开读取(从配置获取) + bool.TryParse(configService["Storage:CosStoragePublicRead"] ?? "false", out _isPublicRead); + + // 在构造函数中初始化客户端,作为单例使用 + _cosXmlClient = CreateClient(); + } + + private CosXml CreateClient() + { + // 优化配置:启用HTTPS和日志 + var config = new CosXmlConfig.Builder() + .IsHttps(true) // 设置默认HTTPS请求 + .SetRegion(_region) + .SetDebugLog(true) // 显示日志 + .Build(); + + // 使用自定义凭证提供者,支持持续更新临时密钥 + var cosCredentialProvider = new CustomQCloudCredentialProvider(_configService); + + return new CosXmlServer(config, cosCredentialProvider); + } + + public async Task SaveAsync(Stream fileStream, string fileName, string contentType) + { + try + { + // 创建唯一的文件存储路径 + string currentDate = DateTime.Now.ToString("yyyy/MM"); + string ext = Path.GetExtension(fileName); + string objectKey = $"{currentDate}/{Guid.NewGuid()}{ext}"; + + // 创建临时文件 + string tempPath = Path.GetTempFileName(); + try + { + using (var fileStream2 = new FileStream(tempPath, FileMode.Create)) + { + await fileStream.CopyToAsync(fileStream2); + } + + var transferConfig = new TransferConfig(); + var transferManager = new TransferManager(_cosXmlClient, transferConfig); + var uploadTask = new COSXMLUploadTask(_bucketName, objectKey); + uploadTask.SetSrcPath(tempPath); + var result = await transferManager.UploadAsync(uploadTask); + return objectKey; + } + finally + { + // 确保临时文件被删除 + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + catch (CosClientException clientEx) + { + Console.WriteLine($"COS客户端异常: {clientEx}"); + throw; + } + catch (CosServerException serverEx) + { + Console.WriteLine($"COS服务器异常: {serverEx.GetInfo()}"); + throw; + } + catch (Exception ex) + { + Console.WriteLine($"上传文件到腾讯云COS时出错: {ex.Message}"); + throw; + } + } + + public async Task DeleteAsync(string storagePath) + { + try + { + if (string.IsNullOrEmpty(storagePath)) + return; + + var request = new DeleteObjectRequest(_bucketName, storagePath); + await Task.Run(() => _cosXmlClient.DeleteObject(request)); + } + catch (CosClientException clientEx) + { + Console.WriteLine($"COS客户端异常: {clientEx}"); + } + catch (CosServerException serverEx) + { + Console.WriteLine($"COS服务器异常: {serverEx.GetInfo()}"); + } + catch (Exception ex) + { + Console.WriteLine($"从腾讯云COS删除文件时出错: {ex.Message}"); + } + } + + public string GetUrl(string storagePath) + { + try + { + if (string.IsNullOrEmpty(storagePath)) + return "/images/unavailable.gif"; + + // 优先使用CDN + if (!string.IsNullOrEmpty(_cdnUrl)) + return $"{_cdnUrl}/{storagePath}"; + + // 公开读取的桶可直接访问 + if (_isPublicRead) + return $"https://{_bucketName}.cos.{_region}.myqcloud.com/{storagePath}"; + + var bucketParts = _bucketName.Split('-'); + var request = new PreSignatureStruct + { + bucket = bucketParts[0], + appid = bucketParts[1], + region = _region, + key = storagePath, + httpMethod = "GET", + isHttps = true, + signDurationSecond = 3600 * 24 + }; + + var url = _cosXmlClient.GenerateSignURL(request); + return url; + } + catch (Exception ex) + { + Console.WriteLine($"生成腾讯云COS文件URL时出错: {ex.Message}"); + return "/images/unavailable.gif"; + } + } + + public async Task DownloadFileAsync(string storagePath) + { + try + { + if (string.IsNullOrEmpty(storagePath)) + { + throw new ArgumentException("存储路径不能为空"); + } + + // 创建临时目录 + var tempDir = Path.Combine(Path.GetTempPath(), "FoxelCosTemp"); + if (!Directory.Exists(tempDir)) + { + Directory.CreateDirectory(tempDir); + } + + string fileName = Path.GetFileName(storagePath); + string localFilePath = Path.Combine(tempDir, fileName); + var transferConfig = new TransferConfig(); + var transferManager = new TransferManager(_cosXmlClient, transferConfig); + var downloadTask = new COSXMLDownloadTask(_bucketName, storagePath, tempDir, fileName); + var result = await transferManager.DownloadAsync(downloadTask); + return localFilePath; + } + catch (CosClientException clientEx) + { + Console.WriteLine($"COS客户端异常: {clientEx}"); + throw; + } + catch (CosServerException serverEx) + { + Console.WriteLine($"COS服务器异常: {serverEx.GetInfo()}"); + throw; + } + catch (Exception ex) + { + Console.WriteLine($"从腾讯云COS下载文件时出错: {ex.Message}"); + throw; + } + } +} diff --git a/Services/StorageProviderFactory.cs b/Services/StorageProviderFactory.cs index 0c81624..32cc90e 100644 --- a/Services/StorageProviderFactory.cs +++ b/Services/StorageProviderFactory.cs @@ -1,12 +1,14 @@ 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) @@ -16,6 +18,7 @@ public class StorageProviderFactory( StorageType.Local => localStorageProvider, StorageType.Telegram => telegramStorageProvider, StorageType.S3 => s3StorageProvider, + StorageType.Cos => cosStorageProvider, _ => throw new ArgumentException($"不支持的存储类型: {storageType}") }; } diff --git a/View/src/config/routeConfig.tsx b/View/src/config/routeConfig.tsx index bc39c7c..de51962 100644 --- a/View/src/config/routeConfig.tsx +++ b/View/src/config/routeConfig.tsx @@ -12,7 +12,6 @@ import AllImages from '../pages/allImages/Index'; import Albums from '../pages/albums/Index'; import AlbumDetail from '../pages/albumDetail/Index'; import Favorites from '../pages/favorites/Index'; -import Upload from '../pages/upload/Index'; import Settings from '../pages/settings/Index'; import BackgroundTasks from '../pages/backgroundTasks/Index'; import PixHub from '../pages/pixHub/Index'; @@ -111,16 +110,6 @@ const routes: RouteConfig[] = [ title: '设置' } }, - { - path: 'upload', - key: 'upload', - label: '上传', - element: , - hideInMenu: true, - breadcrumb: { - title: '上传' - } - }, ]; export default routes; diff --git a/View/src/pages/settings/SystemConfig.tsx b/View/src/pages/settings/SystemConfig.tsx index 4b75d61..f3f70f5 100644 --- a/View/src/pages/settings/SystemConfig.tsx +++ b/View/src/pages/settings/SystemConfig.tsx @@ -85,8 +85,9 @@ const SystemConfig: React.FC = () => { // 存储类型选项 const storageOptions = [ { value: 'Local', label: '本地存储', icon: }, - { value: 'Telegram', label: 'Telegram存储', icon: }, - { value: 'S3', label: 'S3兼容存储', icon: }, + { value: 'Telegram', label: 'Telegram 频道', icon: }, + { value: 'S3', label: '亚马逊 S3', icon: }, + { value: 'Cos', label: '腾讯云 COS', icon: }, ]; useEffect(() => { @@ -292,6 +293,30 @@ const SystemConfig: React.FC = () => { isMobile={isMobile} /> )} + + {storageType === 'Cos' && ( + + )} )} diff --git a/View/src/pages/upload/Index.tsx b/View/src/pages/upload/Index.tsx deleted file mode 100644 index ef3f808..0000000 --- a/View/src/pages/upload/Index.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Typography } from 'antd'; - -const { Title } = Typography; - -function Upload() { - return ( -
- 上传图片 - {/* 上传表单 */} -
- ); -} - -export default Upload;