From 0a6fe705376fab08b3ebffa9aa414b4d74e1f9d3 Mon Sep 17 00:00:00 2001 From: shiyu Date: Mon, 9 Jun 2025 12:12:15 +0800 Subject: [PATCH] feat(storage): implement storage management API and enhance storage mode handling --- Api/Management/StorageManagementController.cs | 193 ++++++ Api/PictureController.cs | 141 ++--- Extensions/ServiceCollectionExtensions.cs | 6 +- Models/DataBase/Picture.cs | 4 +- Models/DataBase/StorageMode.cs | 16 + .../Request/Picture/UploadPictureRequest.cs | 6 +- .../Storage/CreateStorageModeRequest.cs | 18 + .../Storage/UpdateStorageModeRequest.cs | 21 + Models/Response/Picture/PictureResponse.cs | 2 + .../Response/Storage/StorageModeResponse.cs | 15 + .../Response/Storage/StorageTypeResponse.cs | 7 + MyDbContext.cs | 1 + .../Processors/PictureTaskProcessor.cs | 161 +++-- .../VisualRecognitionTaskProcessor.cs | 127 ++-- Services/Configuration/ConfigService.cs | 6 - Services/Initializer/DatabaseInitializer.cs | 47 +- .../Management/IStorageManagementService.cs | 19 + .../Management/PictureManagementService.cs | 10 +- .../Management/StorageManagementService.cs | 310 ++++++++++ Services/Media/IPictureService.cs | 11 +- Services/Media/PictureService.cs | 439 ++++++++------ Services/Storage/IStorageProvider.cs | 2 +- Services/Storage/IStorageService.cs | 12 +- .../Storage/Providers/CosStorageProvider.cs | 94 ++- .../Storage/Providers/LocalStorageProvider.cs | 127 +++- .../Storage/Providers/S3StorageProvider.cs | 69 ++- .../Providers/TelegramStorageProvider.cs | 78 ++- .../Providers/WebDAVStorageProvider.cs | 63 +- Services/Storage/StorageService.cs | 117 +++- Utils/ImageHelper.cs | 48 +- Web/src/api/fetchClient.ts | 7 + Web/src/api/index.ts | 3 +- Web/src/api/logManagementApi.ts | 3 +- Web/src/api/pictureApi.ts | 1 + Web/src/api/pictureManagementApi.ts | 3 +- Web/src/api/storageManagementApi.ts | 156 +++++ Web/src/api/userManagementApi.ts | 9 +- .../components/image/ImageGrid/ImageCard.tsx | 15 +- .../components/image/ImageGrid/ImageGrid.css | 21 + .../pages/admin/storage/StorageManagement.tsx | 571 ++++++++++++++++++ Web/src/pages/admin/system/ConfigTabs.tsx | 298 +++------ Web/src/pages/admin/system/Index.tsx | 87 +-- Web/src/routes/index.tsx | 12 + 43 files changed, 2449 insertions(+), 907 deletions(-) create mode 100644 Api/Management/StorageManagementController.cs create mode 100644 Models/DataBase/StorageMode.cs create mode 100644 Models/Request/Storage/CreateStorageModeRequest.cs create mode 100644 Models/Request/Storage/UpdateStorageModeRequest.cs create mode 100644 Models/Response/Storage/StorageModeResponse.cs create mode 100644 Models/Response/Storage/StorageTypeResponse.cs create mode 100644 Services/Management/IStorageManagementService.cs create mode 100644 Services/Management/StorageManagementService.cs create mode 100644 Web/src/api/storageManagementApi.ts create mode 100644 Web/src/pages/admin/storage/StorageManagement.tsx diff --git a/Api/Management/StorageManagementController.cs b/Api/Management/StorageManagementController.cs new file mode 100644 index 0000000..3c4dc59 --- /dev/null +++ b/Api/Management/StorageManagementController.cs @@ -0,0 +1,193 @@ +using Foxel.Controllers; +using Foxel.Models; +using Foxel.Models.Request.Storage; +using Foxel.Models.Response.Storage; +using Foxel.Services.Attributes; +using Foxel.Services.Management; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Foxel.Api.Management; + +[Authorize(Roles = "Administrator")] +[Route("api/management/storage")] +public class StorageManagementController(IStorageManagementService storageManagementService) : BaseApiController +{ + [HttpGet("get_modes")] + public async Task>> GetStorageModes( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 10, + [FromQuery] string? searchQuery = null, + [FromQuery] StorageType? storageType = null, + [FromQuery] bool? isEnabled = null) + { + try + { + var result = + await storageManagementService.GetStorageModesAsync(page, pageSize, searchQuery, storageType, + isEnabled); + return PaginatedSuccess(result.Data, result.TotalCount, result.Page, result.PageSize); + } + catch (Exception ex) + { + return PaginatedError($"Failed to get storage modes: {ex.Message}", 500); + } + } + + [HttpGet("get_mode/{id}")] + public async Task>> GetStorageModeById(int id) + { + try + { + var result = await storageManagementService.GetStorageModeByIdAsync(id); + return Success(result, "Storage mode retrieved successfully."); + } + catch (KeyNotFoundException ex) + { + return Error(ex.Message, 404); + } + catch (Exception ex) + { + return Error($"Failed to get storage mode: {ex.Message}", 500); + } + } + + [HttpPost("create_mode")] + public async Task>> CreateStorageMode( + [FromBody] CreateStorageModeRequest request) + { + try + { + var result = await storageManagementService.CreateStorageModeAsync(request); + return Success(result, "Storage mode created successfully."); + } + catch (ArgumentException ex) + { + return Error(ex.Message, 400); + } + catch (Exception ex) + { + return Error($"Failed to create storage mode: {ex.Message}", 500); + } + } + + [HttpPost("update_mode")] + public async Task>> UpdateStorageMode( + [FromBody] UpdateStorageModeRequest request) + { + try + { + var result = await storageManagementService.UpdateStorageModeAsync(request); + return Success(result, "Storage mode updated successfully."); + } + catch (KeyNotFoundException ex) + { + return Error(ex.Message, 404); + } + catch (ArgumentException ex) + { + return Error(ex.Message, 400); + } + catch (Exception ex) + { + return Error($"Failed to update storage mode: {ex.Message}", 500); + } + } + + [HttpPost("delete_mode")] + public async Task>> DeleteStorageMode([FromBody] int id) + { + try + { + var result = await storageManagementService.DeleteStorageModeAsync(id); + return Success(result, "Storage mode deleted successfully."); + } + catch (KeyNotFoundException ex) + { + return Error(ex.Message, 404); + } + catch (InvalidOperationException ex) // Catch specific exception for "in use" + { + return Error(ex.Message, 400); + } + catch (Exception ex) + { + return Error($"Failed to delete storage mode: {ex.Message}", 500); + } + } + + [HttpPost("batch_delete_modes")] + public async Task>> BatchDeleteStorageModes([FromBody] List ids) + { + try + { + if (ids == null || !ids.Any()) + { + return Error("No IDs provided for batch deletion.", 400); + } + + var result = await storageManagementService.BatchDeleteStorageModesAsync(ids); + return Success(result, + $"Batch delete completed. Succeeded: {result.SuccessCount}, Failed: {result.FailedCount}."); + } + catch (Exception ex) + { + return Error($"Batch delete failed: {ex.Message}", 500); + } + } + + [HttpGet("get_storage_types")] + public async Task>>> GetStorageTypes() + { + try + { + var result = await storageManagementService.GetStorageTypesAsync(); + return Success(result, "Storage types retrieved successfully."); + } + catch (Exception ex) + { + return Error>($"Failed to get storage types: {ex.Message}", 500); + } + } + + [HttpGet("get_default_mode_id")] + public async Task>> GetDefaultStorageModeId() + { + try + { + var result = await storageManagementService.GetDefaultStorageModeIdAsync(); + if (result.HasValue) + { + return Success(result.Value, "Default storage mode ID retrieved successfully."); + } + + return Success(null, "No default storage mode is currently set or the configured one is invalid."); + } + catch (Exception ex) + { + return Error($"Failed to get default storage mode ID: {ex.Message}", 500); + } + } + + [HttpPost("set_default_mode/{id}")] + public async Task>> SetDefaultStorageMode(int id) + { + try + { + var result = await storageManagementService.SetDefaultStorageModeAsync(id); + return Success(result, $"Default storage mode set to ID {id} successfully."); + } + catch (KeyNotFoundException ex) + { + return Error(ex.Message, 404); + } + catch (InvalidOperationException ex) + { + return Error(ex.Message, 400); + } + catch (Exception ex) + { + return Error($"Failed to set default storage mode: {ex.Message}", 500); + } + } +} \ No newline at end of file diff --git a/Api/PictureController.cs b/Api/PictureController.cs index 6c8fc49..da46de1 100644 --- a/Api/PictureController.cs +++ b/Api/PictureController.cs @@ -1,22 +1,23 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using System.Text.Json.Serialization; +using Foxel.Controllers; using Foxel.Models; using Foxel.Models.DataBase; using Foxel.Models.Request.Picture; using Foxel.Models.Response.Picture; -using System.Text.Json; -using System.Text.Json.Serialization; using Foxel.Services.Media; using Foxel.Services.Storage; -using System.IO; -using Foxel.Services.Attributes; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; // Added for JsonSerializer in GetTelegramFile if it were kept -namespace Foxel.Controllers; +namespace Foxel.Api; [Authorize] [Route("api/picture")] -public class PictureController(IPictureService pictureService, IStorageService storageService) : BaseApiController +public class PictureController(IPictureService pictureService, IStorageService storageService, ILogger logger) : BaseApiController { + private readonly ILogger _logger = logger; + [HttpGet("get_pictures")] public async Task>> GetPictures( [FromQuery] FilteredPicturesRequest request) @@ -81,7 +82,7 @@ public class PictureController(IPictureService pictureService, IStorageService s userId, (PermissionType)request.Permission!, request.AlbumId, - request.StorageType + request.StorageModeId ); var picture = result.Picture; @@ -260,92 +261,76 @@ public class PictureController(IPictureService pictureService, IStorageService s } } - [HttpGet("get_telegram_file")] + [HttpGet("file/{pictureId}")] [AllowAnonymous] - public async Task GetTelegramFile([FromQuery] string fileId) + public async Task GetPictureFile(int pictureId) { try { - // 创建一个模拟的存储元数据 - var metadata = new + var picture = await pictureService.GetPictureByIdAsync(pictureId); + if (picture == null) { - FileId = fileId, - OriginalFileName = "telegram_file" - }; - - // 序列化为 JSON 字符串,与 TelegramStorageProvider 中的格式保持一致 - string storagePath = JsonSerializer.Serialize(metadata); - - try - { - // 使用 storageService 下载文件,这样会自动使用配置的代理 - string tempFilePath = await storageService.ExecuteAsync(StorageType.Telegram, - provider => provider.DownloadFileAsync(storagePath)); - - // 获取文件内容类型 - string contentType = GetContentTypeFromPath(tempFilePath); - - // 返回文件 - return PhysicalFile(tempFilePath, contentType, Path.GetFileName(tempFilePath)); + _logger.LogWarning("GetPictureFile: Picture with ID {PictureId} not found.", pictureId); + return NotFound("Picture not found."); } - catch (Exception ex) + var currentUserId = GetCurrentUserId(); + if (picture.Permission != PermissionType.Public) { - return StatusCode(500, $"下载 Telegram 文件失败: {ex.Message}"); + if (currentUserId == null || picture.UserId != currentUserId.Value) + { + _logger.LogWarning("GetPictureFile: User {UserId} forbidden to access picture {PictureId}.", currentUserId, pictureId); + return Forbid(); + } } + + // 3. 使用 StorageService 下载文件 + string tempFilePath = await storageService.ExecuteAsync( + picture.StorageModeId, + provider => provider.DownloadFileAsync(picture.Path) + ); + + if (string.IsNullOrEmpty(tempFilePath) || !System.IO.File.Exists(tempFilePath)) + { + _logger.LogError("GetPictureFile: Failed to download file or file not found at temp path for picture ID {PictureId}. TempPath: {TempPath}", pictureId, tempFilePath); + return StatusCode(500, "Failed to retrieve file from storage."); + } + // 4. 确定内容类型 + string contentType = GetContentTypeFromPath(tempFilePath); + + // 5. 返回文件 + return PhysicalFile(tempFilePath, contentType, Path.GetFileName(picture.Name)); + } + catch (KeyNotFoundException knfEx) + { + _logger.LogWarning(knfEx, "GetPictureFile: Resource not found for picture ID {PictureId}.", pictureId); + return NotFound($"Resource related to picture ID {pictureId} not found."); + } + catch (FileNotFoundException fnfEx) + { + _logger.LogWarning(fnfEx, "GetPictureFile: File not found in storage for picture ID {PictureId}.", pictureId); + return NotFound("File not found in storage."); + } + catch (NotImplementedException niEx) + { + _logger.LogError(niEx, "GetPictureFile: DownloadFileAsync not implemented for the storage provider of picture ID {PictureId}.", pictureId); + return StatusCode(501, "File download is not supported for this storage type."); + } + catch (InvalidOperationException ioEx) + { + _logger.LogError(ioEx, "GetPictureFile: Invalid operation for picture ID {PictureId}.", pictureId); + return StatusCode(500, $"Error processing file request: {ioEx.Message}"); } catch (Exception ex) { - return StatusCode(500, $"代理获取文件失败: {ex.Message}"); - } - } - - // 用于解析 Telegram getFile API 响应的辅助类 - private class TelegramGetFileResponse - { - [JsonPropertyName("ok")] - public bool Ok { get; set; } - - [JsonPropertyName("result")] - public TelegramFileResult? Result { get; set; } - } - - private class TelegramFileResult - { - [JsonPropertyName("file_path")] - public string? FilePath { get; set; } - } - - [HttpGet("proxy")] - [AllowAnonymous] - public async Task GetWebDavFile([FromQuery] string path) - { - try - { - if (string.IsNullOrEmpty(path)) - { - return BadRequest("文件路径不能为空"); - } - - // 下载文件到临时位置 - string filePath = await storageService.ExecuteAsync(StorageType.WebDAV, - provider => provider.DownloadFileAsync(path)); - - // 确定内容类型 - string contentType = GetContentTypeFromPath(path); - - // 返回文件内容 - return PhysicalFile(filePath, contentType, Path.GetFileName(path)); - } - catch (Exception ex) - { - return StatusCode(500, $"代理获取WebDAV文件失败: {ex.Message}"); + _logger.LogError(ex, "GetPictureFile: Error getting file for picture ID {PictureId}", pictureId); + return StatusCode(500, "An error occurred while retrieving the file."); } } private string GetContentTypeFromPath(string path) { string extension = Path.GetExtension(path).ToLowerInvariant(); - + return extension switch { ".jpg" or ".jpeg" => "image/jpeg", diff --git a/Extensions/ServiceCollectionExtensions.cs b/Extensions/ServiceCollectionExtensions.cs index 636422f..d30f177 100644 --- a/Extensions/ServiceCollectionExtensions.cs +++ b/Extensions/ServiceCollectionExtensions.cs @@ -31,13 +31,9 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddHostedService(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Models/DataBase/Picture.cs b/Models/DataBase/Picture.cs index 7d8930d..9a36760 100644 --- a/Models/DataBase/Picture.cs +++ b/Models/DataBase/Picture.cs @@ -35,7 +35,9 @@ public class Picture : BaseModel set => ExifInfoJson = value != null ? JsonSerializer.Serialize(value) : null; } - public StorageType StorageType { get; set; } = StorageType.Local; + public int StorageModeId { get; set; } + [ForeignKey("StorageModeId")] + public StorageMode? StorageMode { get; set; } = null!; public ICollection? Tags { get; set; } public int? UserId { get; set; } diff --git a/Models/DataBase/StorageMode.cs b/Models/DataBase/StorageMode.cs new file mode 100644 index 0000000..33b4be0 --- /dev/null +++ b/Models/DataBase/StorageMode.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Foxel.Services.Attributes; + +namespace Foxel.Models.DataBase; + +public class StorageMode : BaseModel +{ + [Required] + [StringLength(100)] + public string Name { get; set; } = string.Empty; + public bool IsEnabled { get; set; } = true; + + public StorageType StorageType { get; set; } = StorageType.Local; + [Column(TypeName = "jsonb")] public string? ConfigurationJson { get; set; } +} diff --git a/Models/Request/Picture/UploadPictureRequest.cs b/Models/Request/Picture/UploadPictureRequest.cs index d92d24e..e4d07e0 100644 --- a/Models/Request/Picture/UploadPictureRequest.cs +++ b/Models/Request/Picture/UploadPictureRequest.cs @@ -1,7 +1,5 @@ using System.ComponentModel.DataAnnotations; -using Foxel.Models.DataBase; -using Foxel.Models.Enums; -using Foxel.Services.Attributes; + namespace Foxel.Models.Request.Picture; @@ -15,5 +13,5 @@ public record UploadPictureRequest public int? AlbumId { get; set; } - public StorageType? StorageType { get; set; } + public int? StorageModeId { get; set; } } \ No newline at end of file diff --git a/Models/Request/Storage/CreateStorageModeRequest.cs b/Models/Request/Storage/CreateStorageModeRequest.cs new file mode 100644 index 0000000..50c2b06 --- /dev/null +++ b/Models/Request/Storage/CreateStorageModeRequest.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using Foxel.Services.Attributes; // For StorageType enum + +namespace Foxel.Models.Request.Storage; + +public class CreateStorageModeRequest +{ + [Required] + [StringLength(100)] + public string Name { get; set; } = string.Empty; + + [Required] + public StorageType StorageType { get; set; } + + public string? ConfigurationJson { get; set; } + + public bool IsEnabled { get; set; } = true; +} diff --git a/Models/Request/Storage/UpdateStorageModeRequest.cs b/Models/Request/Storage/UpdateStorageModeRequest.cs new file mode 100644 index 0000000..7216f4a --- /dev/null +++ b/Models/Request/Storage/UpdateStorageModeRequest.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using Foxel.Services.Attributes; // For StorageType enum + +namespace Foxel.Models.Request.Storage; + +public class UpdateStorageModeRequest +{ + [Required] + public int Id { get; set; } + + [Required] + [StringLength(100)] + public string Name { get; set; } = string.Empty; + + [Required] + public StorageType StorageType { get; set; } + + public string? ConfigurationJson { get; set; } + + public bool IsEnabled { get; set; } +} diff --git a/Models/Response/Picture/PictureResponse.cs b/Models/Response/Picture/PictureResponse.cs index 1f4aa3e..479f404 100644 --- a/Models/Response/Picture/PictureResponse.cs +++ b/Models/Response/Picture/PictureResponse.cs @@ -21,4 +21,6 @@ public record PictureResponse public int? AlbumId { get; set; } public string? AlbumName { get; set; } public PermissionType Permission { get; set; } = PermissionType.Public; + public string? StorageModeName { get; set; } + } diff --git a/Models/Response/Storage/StorageModeResponse.cs b/Models/Response/Storage/StorageModeResponse.cs new file mode 100644 index 0000000..a0535a3 --- /dev/null +++ b/Models/Response/Storage/StorageModeResponse.cs @@ -0,0 +1,15 @@ +using Foxel.Services.Attributes; // For StorageType enum + +namespace Foxel.Models.Response.Storage; + +public class StorageModeResponse +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public StorageType StorageType { get; set; } + public string StorageTypeName => StorageType.ToString(); + public string? ConfigurationJson { get; set; } // Consider if this should be exposed or masked/summarized + public bool IsEnabled { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/Models/Response/Storage/StorageTypeResponse.cs b/Models/Response/Storage/StorageTypeResponse.cs new file mode 100644 index 0000000..6152092 --- /dev/null +++ b/Models/Response/Storage/StorageTypeResponse.cs @@ -0,0 +1,7 @@ +namespace Foxel.Models.Response.Storage; + +public class StorageTypeResponse +{ + public int Value { get; set; } + public string Name { get; set; } = string.Empty; +} diff --git a/MyDbContext.cs b/MyDbContext.cs index 99b5657..8b5b9cc 100644 --- a/MyDbContext.cs +++ b/MyDbContext.cs @@ -14,4 +14,5 @@ public class MyDbContext(DbContextOptions options) : DbContext(opti public DbSet Roles { get; set; } = null!; public DbSet Logs { get; set; } = null!; public DbSet BackgroundTasks { get; set; } = null!; + public DbSet StorageModes { get; set; } = null!; } \ No newline at end of file diff --git a/Services/Background/Processors/PictureTaskProcessor.cs b/Services/Background/Processors/PictureTaskProcessor.cs index 389e6cd..13ce473 100644 --- a/Services/Background/Processors/PictureTaskProcessor.cs +++ b/Services/Background/Processors/PictureTaskProcessor.cs @@ -1,49 +1,30 @@ using Foxel.Models.DataBase; -using Foxel.Services.AI; using Foxel.Services.Storage; -using Foxel.Services.VectorDB; using Foxel.Utils; using Microsoft.EntityFrameworkCore; using System.Text.Json; -using Foxel.Services.Attributes; -using Foxel.Services.Background; // Added for IBackgroundTaskQueue namespace Foxel.Services.Background.Processors { - public class PictureProcessingPayload // Ensure this is defined or imported + public class PictureProcessingPayload { public int PictureId { get; set; } public string OriginalFilePath { get; set; } = string.Empty; public int? UserIdForPicture { get; set; } } - - - public class PictureTaskProcessor : ITaskProcessor + public class PictureTaskProcessor( + IDbContextFactory contextFactory, + IServiceProvider serviceProvider, + ILogger logger) + : ITaskProcessor { - private readonly IDbContextFactory _contextFactory; - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - private readonly IWebHostEnvironment _environment; - - public PictureTaskProcessor( - IDbContextFactory contextFactory, - IServiceProvider serviceProvider, - ILogger logger, - IWebHostEnvironment environment) - { - _contextFactory = contextFactory; - _serviceProvider = serviceProvider; - _logger = logger; - _environment = environment; - } - public async Task ProcessAsync(BackgroundTask backgroundTask) { if (backgroundTask.Payload == null) { await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "任务 Payload 为空。"); - _logger.LogError("任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id); + logger.LogError("任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id); return; } @@ -54,125 +35,139 @@ namespace Foxel.Services.Background.Processors } catch (JsonException ex) { - _logger.LogError(ex, "无法解析图片处理任务的 Payload: TaskId={TaskId}", backgroundTask.Id); + logger.LogError(ex, "无法解析图片处理任务的 Payload: TaskId={TaskId}", backgroundTask.Id); await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 解析失败。"); return; } if (payload == null || payload.PictureId == 0) { - _logger.LogError("图片处理任务的 Payload 无效或缺少 PictureId: TaskId={TaskId}", backgroundTask.Id); - await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 无效或缺少 PictureId。"); + logger.LogError("图片处理任务的 Payload 无效或缺少 PictureId: TaskId={TaskId}", backgroundTask.Id); + await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, + "Payload 无效或缺少 PictureId。"); return; } var pictureId = payload.PictureId; - var originalFilePathFromPayload = payload.OriginalFilePath; + var storageKeyForOriginalFile = payload.OriginalFilePath; string localFilePath = ""; bool isTempFile = false; - // string thumbnailForAI = string.Empty; // No longer directly used for AI here - await using var dbContext = await _contextFactory.CreateDbContextAsync(); + await using var dbContext = await contextFactory.CreateDbContextAsync(); var currentBackgroundTaskState = await dbContext.BackgroundTasks.FindAsync(backgroundTask.Id); if (currentBackgroundTaskState == null) { - _logger.LogError("在 PictureTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id); + logger.LogError("在 PictureTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id); return; } - var picture = await dbContext.Pictures.Include(p => p.User).FirstOrDefaultAsync(p => p.Id == pictureId); + // Include StorageMode to access its StorageType + var picture = await dbContext.Pictures + .Include(p => p.User) + .Include(p => p.StorageMode) + .FirstOrDefaultAsync(p => p.Id == pictureId); try { - await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 10, currentBackgroundTaskState: currentBackgroundTaskState); + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 10, + currentBackgroundTaskState: currentBackgroundTaskState); if (picture == null) { throw new Exception($"找不到ID为{pictureId}的图片。"); } - using var scope = _serviceProvider.CreateScope(); + if (picture.StorageMode == null || picture.StorageModeId < 0) + { + throw new Exception($"图片ID {pictureId} 缺少有效的 StorageMode 配置。"); + } + + using var scope = serviceProvider.CreateScope(); var storageService = scope.ServiceProvider.GetRequiredService(); - string contentRootPath = _environment.ContentRootPath; - - if (picture.StorageType == StorageType.Local) + if (picture.StorageMode.StorageType == Attributes.StorageType.Local) { - localFilePath = Path.Combine(contentRootPath, originalFilePathFromPayload.TrimStart('/')); - } - else - { - await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 25, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress - localFilePath = await storageService.ExecuteAsync(picture.StorageType, - provider => provider.DownloadFileAsync(originalFilePathFromPayload)); - isTempFile = true; + logger.LogInformation( + "Picture {PictureId} is Local. Attempting to download via StorageService for consistency.", + pictureId); } + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 25, + currentBackgroundTaskState: currentBackgroundTaskState); + localFilePath = await storageService.ExecuteAsync(picture.StorageModeId, + provider => provider.DownloadFileAsync(storageKeyForOriginalFile)); // Use storageKeyForOriginalFile + isTempFile = true; if (string.IsNullOrEmpty(localFilePath) || !File.Exists(localFilePath)) { - throw new Exception($"找不到图片文件: {localFilePath} (源路径: {originalFilePathFromPayload})"); + throw new Exception($"找不到图片文件: {localFilePath} (源存储路径: {storageKeyForOriginalFile})"); } - await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 50, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress - + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 50, + currentBackgroundTaskState: currentBackgroundTaskState); if (string.IsNullOrEmpty(picture.ThumbnailPath)) { var tempThumbContainer = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(tempThumbContainer); - - // Derive baseName from OriginalPath (which is in originalFilePathFromPayload) - // originalFilePathFromPayload is the stored path/key, not a local path. - // We need the base name (UUID part) from the picture's OriginalPath. - string baseNameFromOriginalPath = Path.GetFileNameWithoutExtension(picture.OriginalPath); - - var thumbnailDiskPath = Path.Combine(tempThumbContainer, $"{baseNameFromOriginalPath}-thumbnail-temp.webp"); - + string baseNameFromOriginalStorageKey = Path.GetFileNameWithoutExtension(picture.OriginalPath); + var thumbnailDiskPath = Path.Combine(tempThumbContainer, + $"{baseNameFromOriginalStorageKey}-thumbnail-temp.webp"); await ImageHelper.CreateThumbnailAsync(localFilePath, thumbnailDiskPath, 500); - // thumbnailForAI = thumbnailDiskPath; // This temp path is for AI, but AI is in next step - - await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 65, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress - - await using var thumbnailFileStream = new FileStream(thumbnailDiskPath, FileMode.Open, FileAccess.Read); - // Use the new naming convention for storage - var thumbnailStorageFileName = $"{baseNameFromOriginalPath}-thumbnail.webp"; - + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 65, + currentBackgroundTaskState: currentBackgroundTaskState); + await using var thumbnailFileStream = + new FileStream(thumbnailDiskPath, FileMode.Open, FileAccess.Read); + var thumbnailStorageFileName = $"{baseNameFromOriginalStorageKey}-thumbnail.webp"; string storedThumbnailPath = await storageService.ExecuteAsync( - picture.StorageType, + picture.StorageModeId, provider => provider.SaveAsync(thumbnailFileStream, thumbnailStorageFileName, "image/webp")); picture.ThumbnailPath = storedThumbnailPath; - if (Directory.Exists(tempThumbContainer)) Directory.Delete(tempThumbContainer, true); } - await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 80, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress + + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 80, + currentBackgroundTaskState: currentBackgroundTaskState); var exifInfo = await ImageHelper.ExtractExifInfoAsync(localFilePath); picture.ExifInfo = exifInfo; picture.TakenAt = ImageHelper.ParseExifDateTime(exifInfo.DateTimeOriginal); await dbContext.SaveChangesAsync(); - await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Completed, 100, completedAt: DateTime.UtcNow, currentBackgroundTaskState: currentBackgroundTaskState); - + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Completed, 100, + completedAt: DateTime.UtcNow, currentBackgroundTaskState: currentBackgroundTaskState); } catch (Exception ex) { - _logger.LogError(ex, "图片元数据处理任务失败: TaskId={TaskId}, PictureId={PictureId}", currentBackgroundTaskState.Id, pictureId); - await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed, currentBackgroundTaskState.Progress, ex.Message, currentBackgroundTaskState: currentBackgroundTaskState); + logger.LogError(ex, "图片元数据处理任务失败: TaskId={TaskId}, PictureId={PictureId}", + currentBackgroundTaskState.Id, pictureId); + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed, + currentBackgroundTaskState.Progress, ex.Message, + currentBackgroundTaskState: currentBackgroundTaskState); } finally { if (isTempFile && File.Exists(localFilePath)) { - try { File.Delete(localFilePath); } catch (Exception ex) { _logger.LogWarning(ex, "删除临时主图片文件失败: {FilePath}", localFilePath); } + try + { + File.Delete(localFilePath); + } + catch (Exception ex) + { + logger.LogWarning(ex, "删除临时主图片文件失败: {FilePath}", localFilePath); + } } } } - private async Task UpdateTaskStatusInDb(Guid taskId, TaskExecutionStatus status, int progress, string? error = null, DateTime? startedAt = null, DateTime? completedAt = null, BackgroundTask? currentBackgroundTaskState = null) + private async Task UpdateTaskStatusInDb(Guid taskId, TaskExecutionStatus status, int progress, + string? error = null, DateTime? startedAt = null, DateTime? completedAt = null, + BackgroundTask? currentBackgroundTaskState = null) { - await using var dbContext = await _contextFactory.CreateDbContextAsync(); + await using var dbContext = await contextFactory.CreateDbContextAsync(); var taskToUpdate = currentBackgroundTaskState ?? await dbContext.BackgroundTasks.FindAsync(taskId); if (taskToUpdate != null) { - if (currentBackgroundTaskState != null && dbContext.Entry(currentBackgroundTaskState).State == EntityState.Detached) + if (currentBackgroundTaskState != null && + dbContext.Entry(currentBackgroundTaskState).State == EntityState.Detached) { dbContext.BackgroundTasks.Attach(currentBackgroundTaskState); } @@ -183,23 +178,25 @@ namespace Foxel.Services.Background.Processors if (startedAt.HasValue) taskToUpdate.StartedAt = startedAt; if (completedAt.HasValue) taskToUpdate.CompletedAt = completedAt; - if ((status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) && !taskToUpdate.StartedAt.HasValue) + if ((status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) && + !taskToUpdate.StartedAt.HasValue) { taskToUpdate.StartedAt = taskToUpdate.CreatedAt; } + if (status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) { taskToUpdate.CompletedAt ??= DateTime.UtcNow; } - await dbContext.SaveChangesAsync(); - _logger.LogInformation("任务状态更新 (Processor): TaskId={TaskId}, Status={Status}, Progress={Progress}%", taskId, status, progress); + logger.LogInformation("任务状态更新 (Processor): TaskId={TaskId}, Status={Status}, Progress={Progress}%", + taskId, status, progress); } else { - _logger.LogWarning("尝试在 Processor 中更新不存在的任务状态: TaskId={TaskId}", taskId); + logger.LogWarning("尝试在 Processor 中更新不存在的任务状态: TaskId={TaskId}", taskId); } } } -} +} \ No newline at end of file diff --git a/Services/Background/Processors/VisualRecognitionTaskProcessor.cs b/Services/Background/Processors/VisualRecognitionTaskProcessor.cs index d948253..f1cbbc0 100644 --- a/Services/Background/Processors/VisualRecognitionTaskProcessor.cs +++ b/Services/Background/Processors/VisualRecognitionTaskProcessor.cs @@ -5,7 +5,10 @@ using Foxel.Services.VectorDB; using Foxel.Utils; using Microsoft.EntityFrameworkCore; using System.Text.Json; -using Foxel.Services.Attributes; +// using Foxel.Services.Attributes; // StorageType enum might not be directly needed here anymore +using Microsoft.Extensions.DependencyInjection; // For CreateScope +using Microsoft.AspNetCore.Hosting; // For IWebHostEnvironment +using Microsoft.Extensions.Logging; // For ILogger namespace Foxel.Services.Background.Processors { @@ -15,19 +18,32 @@ namespace Foxel.Services.Background.Processors public int? UserIdForPicture { get; set; } } - public class VisualRecognitionTaskProcessor( - IDbContextFactory contextFactory, - IServiceProvider serviceProvider, - ILogger logger, - IWebHostEnvironment environment) - : ITaskProcessor + public class VisualRecognitionTaskProcessor : ITaskProcessor { + private readonly IDbContextFactory _contextFactory; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly IWebHostEnvironment _environment; + + public VisualRecognitionTaskProcessor( + IDbContextFactory contextFactory, + IServiceProvider serviceProvider, + ILogger logger, + IWebHostEnvironment environment) + { + _contextFactory = contextFactory; + _serviceProvider = serviceProvider; + _logger = logger; + _environment = environment; + } + public async Task ProcessAsync(BackgroundTask backgroundTask) { + // ... (payload deserialization and validation logic remains the same) ... if (backgroundTask.Payload == null) { await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "任务 Payload 为空。"); - logger.LogError("视觉识别任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id); + _logger.LogError("视觉识别任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id); return; } @@ -38,33 +54,37 @@ namespace Foxel.Services.Background.Processors } catch (JsonException ex) { - logger.LogError(ex, "无法解析视觉识别任务的 Payload: TaskId={TaskId}", backgroundTask.Id); + _logger.LogError(ex, "无法解析视觉识别任务的 Payload: TaskId={TaskId}", backgroundTask.Id); await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 解析失败。"); return; } if (payload == null || payload.PictureId == 0) { - logger.LogError("视觉识别任务的 Payload 无效或缺少 PictureId: TaskId={TaskId}", backgroundTask.Id); + _logger.LogError("视觉识别任务的 Payload 无效或缺少 PictureId: TaskId={TaskId}", backgroundTask.Id); await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 无效或缺少 PictureId。"); return; } var pictureId = payload.PictureId; - string thumbnailForAiDownloadPath = string.Empty; // Path if thumbnail needs to be downloaded + string thumbnailForAiDownloadPath = string.Empty; bool isTempThumbnailFile = false; - await using var dbContext = await contextFactory.CreateDbContextAsync(); + await using var dbContext = await _contextFactory.CreateDbContextAsync(); var currentBackgroundTaskState = await dbContext.BackgroundTasks.FindAsync(backgroundTask.Id); if (currentBackgroundTaskState == null) { - logger.LogError("在 VisualRecognitionTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id); + _logger.LogError("在 VisualRecognitionTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id); return; } - var picture = await dbContext.Pictures.Include(p => p.User).ThenInclude(u => u.Tags) - .FirstOrDefaultAsync(p => p.Id == pictureId); + var picture = await dbContext.Pictures + .Include(p => p.User) + .ThenInclude(u => u!.Tags) // Ensure Tags on User is loaded if needed + .Include(p => p.StorageMode) // Include StorageMode + .Include(p => p.Tags) // Include picture's own tags + .FirstOrDefaultAsync(p => p.Id == pictureId); try { @@ -75,35 +95,48 @@ namespace Foxel.Services.Background.Processors { throw new Exception($"找不到ID为{pictureId}的图片。"); } + if (picture.StorageMode == null || picture.StorageModeId < 0) + { + throw new Exception($"图片ID {pictureId} 缺少有效的 StorageMode 配置。"); + } if (string.IsNullOrEmpty(picture.ThumbnailPath)) { + // It's possible the thumbnail is generated by PictureTaskProcessor but this task runs before it completes. + // Or thumbnail generation failed. + _logger.LogWarning("图片ID {PictureId} 的缩略图路径为空。AI分析将无法进行或可能失败。", pictureId); + // Depending on requirements, you might throw, or try to generate it here, or skip AI. + // For now, let's assume it should exist. throw new Exception($"图片ID {pictureId} 的缩略图路径为空,无法进行AI分析。"); } - using var scope = serviceProvider.CreateScope(); + using var scope = _serviceProvider.CreateScope(); var aiService = scope.ServiceProvider.GetRequiredService(); var storageService = scope.ServiceProvider.GetRequiredService(); - string contentRootPath = environment.ContentRootPath; + string contentRootPath = _environment.ContentRootPath; string actualThumbnailPathForAI; - if (picture.StorageType == StorageType.Local) + // Check the StorageType of the associated StorageMode + if (picture.StorageMode.StorageType == Attributes.StorageType.Local) { - actualThumbnailPathForAI = Path.Combine(contentRootPath, picture.ThumbnailPath.TrimStart('/')); - } - else // Remote storage - { - await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 15, - currentBackgroundTaskState: currentBackgroundTaskState); - thumbnailForAiDownloadPath = await storageService.ExecuteAsync(picture.StorageType, - provider => provider.DownloadFileAsync(picture.ThumbnailPath)); - actualThumbnailPathForAI = thumbnailForAiDownloadPath; - isTempThumbnailFile = true; + // As with PictureTaskProcessor, safer to use DownloadFileAsync for consistency + _logger.LogInformation("Picture {PictureId} thumbnail is Local. Attempting to download via StorageService for AI.", pictureId); + // Fall-through } + // else // Remote storage or consistent handling for Local + // { + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 15, + currentBackgroundTaskState: currentBackgroundTaskState); + // Use picture.StorageModeId to download the thumbnail + thumbnailForAiDownloadPath = await storageService.ExecuteAsync(picture.StorageModeId, + provider => provider.DownloadFileAsync(picture.ThumbnailPath)); + actualThumbnailPathForAI = thumbnailForAiDownloadPath; + isTempThumbnailFile = true; + // } if (string.IsNullOrEmpty(actualThumbnailPathForAI) || !File.Exists(actualThumbnailPathForAI)) { - throw new Exception($"找不到用于AI分析的缩略图文件: {actualThumbnailPathForAI} (源路径: {picture.ThumbnailPath})"); + throw new Exception($"找不到用于AI分析的缩略图文件: {actualThumbnailPathForAI} (源存储路径: {picture.ThumbnailPath})"); } await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 20, @@ -116,11 +149,11 @@ namespace Foxel.Services.Background.Processors string finalTitle = !string.IsNullOrWhiteSpace(title) && title != "AI生成的标题" ? title - : Path.GetFileNameWithoutExtension(picture.Name); + : Path.GetFileNameWithoutExtension(picture.Name); // Fallback to existing name string finalDescription = !string.IsNullOrWhiteSpace(description) && description != "AI生成的描述" ? description - : picture.Description; - picture.Name = finalTitle; // Potentially overwrites name set from filename + : picture.Description; // Fallback to existing description + picture.Name = finalTitle; picture.Description = finalDescription; await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 60, @@ -135,7 +168,7 @@ namespace Foxel.Services.Background.Processors await vectorDbService.AddPictureToUserCollectionAsync(picture.UserId.Value, new Models.Vector.PictureVector { - Id = (ulong)picture.Id, + Id = (ulong)picture.Id, // Ensure Picture.Id can be cast to ulong or adjust type Name = picture.Name, Embedding = embedding }); @@ -151,7 +184,7 @@ namespace Foxel.Services.Background.Processors await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 90, currentBackgroundTaskState: currentBackgroundTaskState); - if (picture.User != null && matchedTagNames.Any()) + if (matchedTagNames.Any()) // Apply tags even if user is null, if that's desired { picture.Tags ??= new List(); foreach (var tagName in matchedTagNames) @@ -162,12 +195,17 @@ namespace Foxel.Services.Background.Processors { existingTag = new Tag { Name = tagName.Trim(), Description = tagName.Trim() }; dbContext.Tags.Add(existingTag); + // await dbContext.SaveChangesAsync(); // Save tag immediately or batch } if (picture.Tags.All(t => t.Id != existingTag.Id)) picture.Tags.Add(existingTag); - picture.User.Tags ??= new List(); - if (picture.User.Tags.All(t => t.Id != existingTag.Id)) picture.User.Tags.Add(existingTag); + // Add to user's tags if user exists + if (picture.User != null) + { + picture.User.Tags ??= new List(); + if (picture.User.Tags.All(t => t.Id != existingTag.Id)) picture.User.Tags.Add(existingTag); + } } } @@ -177,7 +215,7 @@ namespace Foxel.Services.Background.Processors } catch (Exception ex) { - logger.LogError(ex, "视觉识别任务失败: TaskId={TaskId}, PictureId={PictureId}", currentBackgroundTaskState.Id, + _logger.LogError(ex, "视觉识别任务失败: TaskId={TaskId}, PictureId={PictureId}", currentBackgroundTaskState.Id, pictureId); await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed, currentBackgroundTaskState.Progress, ex.Message, @@ -193,17 +231,18 @@ namespace Foxel.Services.Background.Processors } catch (Exception ex) { - logger.LogWarning(ex, "删除临时AI缩略图文件失败: {FilePath}", thumbnailForAiDownloadPath); + _logger.LogWarning(ex, "删除临时AI缩略图文件失败: {FilePath}", thumbnailForAiDownloadPath); } } } } + // ... (UpdateTaskStatusInDb remains the same) ... private async Task UpdateTaskStatusInDb(Guid taskId, TaskExecutionStatus status, int progress, string? error = null, DateTime? startedAt = null, DateTime? completedAt = null, BackgroundTask? currentBackgroundTaskState = null) { - await using var dbContext = await contextFactory.CreateDbContextAsync(); + await using var dbContext = await _contextFactory.CreateDbContextAsync(); var taskToUpdate = currentBackgroundTaskState ?? await dbContext.BackgroundTasks.FindAsync(taskId); if (taskToUpdate != null) @@ -219,30 +258,30 @@ namespace Foxel.Services.Background.Processors taskToUpdate.ErrorMessage = string.IsNullOrEmpty(error) ? taskToUpdate.ErrorMessage - : error; // Keep existing error if new one is null/empty + : error; if (startedAt.HasValue) taskToUpdate.StartedAt = startedAt; if (completedAt.HasValue) taskToUpdate.CompletedAt = completedAt; if ((status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) && !taskToUpdate.StartedAt.HasValue) { - taskToUpdate.StartedAt = taskToUpdate.CreatedAt; // Ensure StartedAt is set + taskToUpdate.StartedAt = taskToUpdate.CreatedAt; } if (status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) { - taskToUpdate.CompletedAt ??= DateTime.UtcNow; // Ensure CompletedAt is set + taskToUpdate.CompletedAt ??= DateTime.UtcNow; } await dbContext.SaveChangesAsync(); - logger.LogInformation( + _logger.LogInformation( "任务状态更新 (VisualRecognitionProcessor): TaskId={TaskId}, Status={Status}, Progress={Progress}%", taskId, status, progress); } else { - logger.LogWarning("尝试在 VisualRecognitionProcessor 中更新不存在的任务状态: TaskId={TaskId}", taskId); + _logger.LogWarning("尝试在 VisualRecognitionProcessor 中更新不存在的任务状态: TaskId={TaskId}", taskId); } } } diff --git a/Services/Configuration/ConfigService.cs b/Services/Configuration/ConfigService.cs index b54009a..035ea29 100644 --- a/Services/Configuration/ConfigService.cs +++ b/Services/Configuration/ConfigService.cs @@ -16,12 +16,6 @@ public class ConfigService( { "AI:ApiKey", "Authentication:GitHubClientSecret", - "Storage:TelegramStorageBotToken", - "Storage:S3StorageAccessKey", - "Storage:S3StorageSecretKey", - "Storage:CosStorageSecretId", - "Storage:CosStorageSecretKey", - "Storage:WebDAVPassword", }; // 缓存过期时间 diff --git a/Services/Initializer/DatabaseInitializer.cs b/Services/Initializer/DatabaseInitializer.cs index f511577..d192fee 100644 --- a/Services/Initializer/DatabaseInitializer.cs +++ b/Services/Initializer/DatabaseInitializer.cs @@ -1,4 +1,5 @@ using Foxel.Models.DataBase; +using Foxel.Services.Attributes; using Foxel.Services.Configuration; using Foxel.Services.Logging; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,7 @@ public class DatabaseInitializer( { // 在初始化期间禁用数据库日志记录 DatabaseLogger.SetDatabaseReady(false); - + logger.LogInformation("开始检查数据库初始化状态..."); // 执行数据库迁移 @@ -28,7 +29,6 @@ public class DatabaseInitializer( configService[InitializationFlag] == "true") { logger.LogInformation("数据库已完成初始化,跳过初始化步骤"); - // 启用数据库日志记录 DatabaseLogger.SetDatabaseReady(true); return; } @@ -64,12 +64,14 @@ public class DatabaseInitializer( "Please generate **5 most relevant tags** for the given image. Each tag should be a **short and descriptive** word or phrase that accurately reflects key visual or thematic elements of the image.\n\nReturn your response in **valid JSON format** as shown below:\n\n```json\n{\n \"tags\": [\"标签1\", \"标签2\", \"标签3\", \"标签4\", \"标签5\"]\n}\n```\n\nMake sure the output is **strictly valid JSON**.", ["AI:TagMatchingPrompt"] = "Given a list of tags: `[{tagsText}]`\n\nPlease strictly select only those tags that are **highly relevant** to the following description (select **up to 5**). Only include tags that **exactly or strongly match** the content. If **none** of the tags are a good match, return an **empty array** instead of including loosely related ones.\n\n**Description:**\n{description}\n\nReturn your response in **valid JSON format** as shown below:\n\n```json\n{\n \"tags\": [\"标签1\", \"标签2\", \"标签3\", \"标签4\", \"标签5\"]\n}\n```\n\n⚠️ Do **not** include code fences (no triple backticks), and ensure the JSON is **valid** and includes only truly matching tag names.", - // 存储配置 - ["Storage:TelegramStorageBotToken"] = "", - ["Storage:TelegramStorageChatId"] = "", - ["Storage:DefaultStorage"] = "Local", + + // 上传配置 + ["Upload:HighQualityImageCompressionQuality"] = "85", + ["Upload:ThumbnailMaxWidth"] = "500", + ["Upload:ThumbnailCompressionQuality"] = "75", // 其他配置 + ["Storage:DefaultStorageModeId"] = "1", ["AppSettings:ServerUrl"] = "", ["VectorDb:Type"] = "InMemory" }; @@ -88,11 +90,14 @@ public class DatabaseInitializer( // 初始化管理员角色和用户 await InitializeAdminRoleAndUserAsync(); + // 初始化默认存储模式 + await InitializeDefaultStorageModeAsync(); + // 标记初始化已完成 await configService.SetConfigAsync(InitializationFlag, "true", "系统初始化完成标志"); logger.LogInformation("数据库配置初始化完成"); - + // 初始化完成后启用数据库日志记录 DatabaseLogger.SetDatabaseReady(true); } @@ -162,4 +167,32 @@ public class DatabaseInitializer( logger.LogInformation("请注意,第一个注册的用户将自动成为管理员"); } + + private async Task InitializeDefaultStorageModeAsync() + { + await using var context = await contextFactory.CreateDbContextAsync(); + + const string defaultStorageModeName = "本地数据"; + if (!await context.StorageModes.AnyAsync(sm => sm.Name == defaultStorageModeName)) + { + logger.LogInformation("创建默认本地存储模式: {StorageModeName}", defaultStorageModeName); + var localDefaultStorageMode = new StorageMode + { + Name = defaultStorageModeName, + IsEnabled = true, + StorageType = StorageType.Local, + ConfigurationJson = + "{\"BasePath\": \"/app/Uploads\", \"ServerUrl\": \"\", \"PublicBasePath\": \"/Uploads\"}", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + await context.StorageModes.AddAsync(localDefaultStorageMode); + await context.SaveChangesAsync(); + logger.LogInformation("默认本地存储模式创建成功"); + } + else + { + logger.LogInformation("默认本地存储模式 '{StorageModeName}' 已存在,跳过创建。", defaultStorageModeName); + } + } } \ No newline at end of file diff --git a/Services/Management/IStorageManagementService.cs b/Services/Management/IStorageManagementService.cs new file mode 100644 index 0000000..c37c606 --- /dev/null +++ b/Services/Management/IStorageManagementService.cs @@ -0,0 +1,19 @@ +using Foxel.Models; // For PaginatedResult +using Foxel.Models.Request.Storage; +using Foxel.Models.Response.Storage; +using Foxel.Services.Attributes; // For StorageType + +namespace Foxel.Services.Management; + +public interface IStorageManagementService +{ + Task> GetStorageModesAsync(int page = 1, int pageSize = 10, string? searchQuery = null, StorageType? storageType = null, bool? isEnabled = null); + Task GetStorageModeByIdAsync(int id); + Task CreateStorageModeAsync(CreateStorageModeRequest request); + Task UpdateStorageModeAsync(UpdateStorageModeRequest request); + Task DeleteStorageModeAsync(int id); + Task BatchDeleteStorageModesAsync(List ids); + Task> GetStorageTypesAsync(); + Task GetDefaultStorageModeIdAsync(); + Task SetDefaultStorageModeAsync(int storageModeId); +} diff --git a/Services/Management/PictureManagementService.cs b/Services/Management/PictureManagementService.cs index 70a1c16..89982f2 100644 --- a/Services/Management/PictureManagementService.cs +++ b/Services/Management/PictureManagementService.cs @@ -48,10 +48,10 @@ public class PictureManagementService( { Id = picture.Id, Name = picture.Name, - Path = storageService.ExecuteAsync(picture.StorageType, provider => - Task.FromResult(provider.GetUrl(picture.Path))).Result, - ThumbnailPath = storageService.ExecuteAsync(picture.StorageType, provider => - Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? string.Empty))).Result, + Path = storageService.ExecuteAsync(picture.StorageModeId, provider => + Task.FromResult(provider.GetUrl(picture.Id,picture.Path))).Result, + ThumbnailPath = storageService.ExecuteAsync(picture.StorageModeId, provider => + Task.FromResult(provider.GetUrl(picture.Id,picture.ThumbnailPath ?? string.Empty))).Result, Description = picture.Description, CreatedAt = picture.CreatedAt, TakenAt = picture.TakenAt, @@ -119,7 +119,7 @@ public class PictureManagementService( // 保存文件路径信息用于后续删除 var filePath = picture.Path; var thumbnailPath = picture.ThumbnailPath; - var storageType = picture.StorageType; + var storageType = picture.StorageModeId; // 删除数据库记录 dbContext.Pictures.Remove(picture); diff --git a/Services/Management/StorageManagementService.cs b/Services/Management/StorageManagementService.cs new file mode 100644 index 0000000..594439f --- /dev/null +++ b/Services/Management/StorageManagementService.cs @@ -0,0 +1,310 @@ +using Foxel.Models; +using Foxel.Models.DataBase; +using Foxel.Models.Request.Storage; +using Foxel.Models.Response.Storage; +using Foxel.Services.Attributes; +using Foxel.Services.Storage.Providers; // Required for config types like LocalStorageConfig, etc. +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using Foxel.Services.Configuration; // Added for IConfigService + +namespace Foxel.Services.Management; + +public class StorageManagementService : IStorageManagementService +{ + private readonly IDbContextFactory _contextFactory; + private readonly ILogger _logger; + private readonly IConfigService _configService; // Added IConfigService + private const string DefaultStorageModeIdKey = "Storage:DefaultStorageModeId"; // Define key for config + + public StorageManagementService( + IDbContextFactory contextFactory, + ILogger logger, + IConfigService configService) // Added IConfigService to constructor + { + _contextFactory = contextFactory; + _logger = logger; + _configService = configService; // Initialize IConfigService + } + + public async Task> GetStorageModesAsync(int page = 1, int pageSize = 10, string? searchQuery = null, StorageType? storageType = null, bool? isEnabled = null) + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 10; + + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var query = dbContext.StorageModes.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(searchQuery)) + { + query = query.Where(sm => sm.Name.Contains(searchQuery)); + } + if (storageType.HasValue) + { + query = query.Where(sm => sm.StorageType == storageType.Value); + } + if (isEnabled.HasValue) + { + query = query.Where(sm => sm.IsEnabled == isEnabled.Value); + } + + query = query.OrderByDescending(sm => sm.CreatedAt); + + var totalCount = await query.CountAsync(); + var storageModes = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var responseItems = storageModes.Select(sm => new StorageModeResponse + { + Id = sm.Id, + Name = sm.Name, + StorageType = sm.StorageType, + ConfigurationJson = sm.ConfigurationJson, // Consider masking sensitive info if necessary + IsEnabled = sm.IsEnabled, + CreatedAt = sm.CreatedAt, + UpdatedAt = sm.UpdatedAt + }).ToList(); + + return new PaginatedResult + { + Data = responseItems, + Page = page, + PageSize = pageSize, + TotalCount = totalCount + }; + } + + public async Task GetStorageModeByIdAsync(int id) + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var sm = await dbContext.StorageModes.FindAsync(id); + if (sm == null) + throw new KeyNotFoundException($"StorageMode with ID {id} not found."); + + return new StorageModeResponse + { + Id = sm.Id, + Name = sm.Name, + StorageType = sm.StorageType, + ConfigurationJson = sm.ConfigurationJson, + IsEnabled = sm.IsEnabled, + CreatedAt = sm.CreatedAt, + UpdatedAt = sm.UpdatedAt + }; + } + + public async Task CreateStorageModeAsync(CreateStorageModeRequest request) + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + + ValidateConfiguration(request.StorageType, request.ConfigurationJson, request.Name); + + var storageMode = new Models.DataBase.StorageMode + { + Name = request.Name, + StorageType = request.StorageType, + ConfigurationJson = request.ConfigurationJson, + IsEnabled = request.IsEnabled, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + dbContext.StorageModes.Add(storageMode); + await dbContext.SaveChangesAsync(); + + return await GetStorageModeByIdAsync(storageMode.Id); // Reuse to get full response model + } + + public async Task UpdateStorageModeAsync(UpdateStorageModeRequest request) + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var storageMode = await dbContext.StorageModes.FindAsync(request.Id); + + if (storageMode == null) + throw new KeyNotFoundException($"StorageMode with ID {request.Id} not found."); + + ValidateConfiguration(request.StorageType, request.ConfigurationJson, request.Name); + + storageMode.Name = request.Name; + storageMode.StorageType = request.StorageType; + storageMode.ConfigurationJson = request.ConfigurationJson; + storageMode.IsEnabled = request.IsEnabled; + storageMode.UpdatedAt = DateTime.UtcNow; + + await dbContext.SaveChangesAsync(); + return await GetStorageModeByIdAsync(storageMode.Id); + } + + public async Task DeleteStorageModeAsync(int id) + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var storageMode = await dbContext.StorageModes.FindAsync(id); + if (storageMode == null) + throw new KeyNotFoundException($"StorageMode with ID {id} not found."); + + // Check if any pictures are using this storage mode + bool isInUse = await dbContext.Pictures.AnyAsync(p => p.StorageModeId == id); + if (isInUse) + { + _logger.LogWarning("Attempted to delete StorageMode ID {StorageModeId} which is currently in use by one or more pictures.", id); + throw new InvalidOperationException($"StorageMode '{storageMode.Name}' (ID: {id}) cannot be deleted because it is currently in use by one or more pictures."); + } + + dbContext.StorageModes.Remove(storageMode); + await dbContext.SaveChangesAsync(); + return true; + } + + public async Task BatchDeleteStorageModesAsync(List ids) + { + var result = new BatchDeleteResult(); + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + + foreach (var id in ids) + { + var storageMode = await dbContext.StorageModes.FindAsync(id); + if (storageMode == null) + { + result.FailedCount++; + result.FailedIds.Add(id); + _logger.LogWarning("Batch delete: StorageMode with ID {StorageModeId} not found.", id); + continue; + } + + bool isInUse = await dbContext.Pictures.AnyAsync(p => p.StorageModeId == id); + if (isInUse) + { + result.FailedCount++; + result.FailedIds.Add(id); + _logger.LogWarning("Batch delete: StorageMode ID {StorageModeId} ('{StorageModeName}') is in use and cannot be deleted.", id, storageMode.Name); + continue; + } + + try + { + dbContext.StorageModes.Remove(storageMode); + result.SuccessCount++; + } + catch (Exception ex) + { + result.FailedCount++; + result.FailedIds.Add(id); + _logger.LogError(ex, "Batch delete: Error deleting StorageMode ID {StorageModeId}.", id); + } + } + if (result.SuccessCount > 0) + { + await dbContext.SaveChangesAsync(); + } + return result; + } + + public Task> GetStorageTypesAsync() + { + var types = Enum.GetValues(typeof(StorageType)) + .Cast() + .Select(e => new StorageTypeResponse { Value = (int)e, Name = e.ToString() }) + .ToList(); + return Task.FromResult>(types); + } + + public async Task GetDefaultStorageModeIdAsync() + { + var idString = await _configService.GetValueAsync(DefaultStorageModeIdKey); + if (int.TryParse(idString, out var id)) + { + // Optionally, verify if this ID still exists and is enabled in StorageModes table + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var storageModeExists = await dbContext.StorageModes.AnyAsync(sm => sm.Id == id && sm.IsEnabled); + if (storageModeExists) + { + return id; + } + _logger.LogWarning("Default storage mode ID {DefaultStorageModeId} from config does not exist or is not enabled.", id); + // If it doesn't exist or isn't enabled, perhaps clear the setting or return null + // For now, returning null as it's not a valid/usable default + await _configService.DeleteConfigAsync(DefaultStorageModeIdKey); // Clean up invalid setting + return null; + } + return null; + } + + public async Task SetDefaultStorageModeAsync(int storageModeId) + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var storageMode = await dbContext.StorageModes.FindAsync(storageModeId); + + if (storageMode == null) + { + _logger.LogWarning("Attempted to set default storage mode to a non-existent ID: {StorageModeId}", storageModeId); + throw new KeyNotFoundException($"StorageMode with ID {storageModeId} not found."); + } + + if (!storageMode.IsEnabled) + { + _logger.LogWarning("Attempted to set default storage mode to a disabled StorageMode ID: {StorageModeId}, Name: {StorageModeName}", storageModeId, storageMode.Name); + throw new InvalidOperationException($"StorageMode '{storageMode.Name}' (ID: {storageModeId}) is disabled and cannot be set as default."); + } + + // Validate configuration before setting as default + try + { + ValidateConfiguration(storageMode.StorageType, storageMode.ConfigurationJson, storageMode.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Validation failed for StorageMode ID {StorageModeId} ('{StorageModeName}') when trying to set as default. Configuration: {ConfigurationJson}", storageModeId, storageMode.Name, storageMode.ConfigurationJson); + throw new InvalidOperationException($"StorageMode '{storageMode.Name}' (ID: {storageModeId}) has an invalid configuration and cannot be set as default. Error: {ex.Message}", ex); + } + + + await _configService.SetConfigAsync(DefaultStorageModeIdKey, storageModeId.ToString(), "The ID of the default storage mode to be used by the application."); + _logger.LogInformation("Default storage mode set to ID: {StorageModeId}, Name: {StorageModeName}", storageModeId, storageMode.Name); + return true; + } + + private void ValidateConfiguration(StorageType storageType, string? jsonConfig, string storageModeName) + { + if (string.IsNullOrWhiteSpace(jsonConfig)) + { + // Configuration can be optional for some types or scenarios, + // but if a type inherently requires config, this check might need adjustment. + // For now, we assume if jsonConfig is null/empty, it's intentional. + // The actual provider instantiation will fail if config is required but missing. + // This validation step is more about format if config IS provided. + return; + } + + try + { + object? deserializedConfig = storageType switch + { + StorageType.Local => JsonSerializer.Deserialize(jsonConfig), + StorageType.Telegram => JsonSerializer.Deserialize(jsonConfig), + StorageType.S3 => JsonSerializer.Deserialize(jsonConfig), + StorageType.Cos => JsonSerializer.Deserialize(jsonConfig), + StorageType.WebDAV => JsonSerializer.Deserialize(jsonConfig), + _ => throw new NotSupportedException($"StorageType {storageType} configuration validation is not supported.") + }; + + if (deserializedConfig == null) + { + throw new JsonException($"Unable to deserialize configuration for {storageType}. JSON: {jsonConfig}"); + } + // Further property-level validation can be added here if needed (e.g., checking required fields within the config object) + } + catch (JsonException ex) + { + _logger.LogError(ex, "Invalid JSON configuration for StorageMode '{StorageModeName}' (Type: {StorageType}). JSON: {JsonConfig}", storageModeName, storageType, jsonConfig); + throw new ArgumentException($"Configuration for StorageMode '{storageModeName}' (Type: {storageType}) is invalid: {ex.Message}", nameof(jsonConfig), ex); + } + catch (NotSupportedException ex) + { + _logger.LogError(ex, "Validation not supported for StorageType '{StorageType}' in StorageMode '{StorageModeName}'.", storageType, storageModeName); + throw new ArgumentException(ex.Message, nameof(storageType), ex); + } + } +} diff --git a/Services/Media/IPictureService.cs b/Services/Media/IPictureService.cs index 88a031f..b0ec10f 100644 --- a/Services/Media/IPictureService.cs +++ b/Services/Media/IPictureService.cs @@ -1,8 +1,6 @@ using Foxel.Models; using Foxel.Models.DataBase; -using Foxel.Models.Enums; using Foxel.Models.Response.Picture; -using Foxel.Services.Attributes; namespace Foxel.Services.Media; @@ -34,7 +32,7 @@ public interface IPictureService int? userId, PermissionType permission = PermissionType.Public, int? albumId = null, - StorageType? storageType = null + int? storageModeId = null ); Task GetPictureExifInfoAsync(int pictureId); @@ -85,5 +83,10 @@ public interface IPictureService /// 是否收藏 Task IsPictureFavoritedByUserAsync(int pictureId, int userId); - + /// + /// 根据ID获取图片详情 + /// + /// 图片ID + /// 图片响应对象,如果未找到则返回null + Task GetPictureByIdAsync(int pictureId); } \ No newline at end of file diff --git a/Services/Media/PictureService.cs b/Services/Media/PictureService.cs index 6fb20a5..5e1d8ec 100644 --- a/Services/Media/PictureService.cs +++ b/Services/Media/PictureService.cs @@ -4,14 +4,12 @@ using Foxel.Models.DataBase; using Foxel.Models.Enums; using Foxel.Models.Response.Picture; using Foxel.Services.AI; -using Foxel.Services.Attributes; using Foxel.Services.Background; using Foxel.Services.Configuration; using Foxel.Services.Storage; using Foxel.Services.VectorDB; using Foxel.Utils; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; namespace Foxel.Services.Media; @@ -25,6 +23,48 @@ public class PictureService( ILogger logger) : IPictureService { + private async Task MapPictureToResponseAsync(Picture picture) + { + string pathUrl = string.Empty; + if (!string.IsNullOrEmpty(picture.Path)) + { + pathUrl = await storageService.ExecuteAsync(picture.StorageModeId, provider => + Task.FromResult(provider.GetUrl(picture.Id,picture.Path))); + } + + string originalPathUrl = string.Empty; + if (!string.IsNullOrEmpty(picture.OriginalPath)) + { + originalPathUrl = await storageService.ExecuteAsync(picture.StorageModeId, provider => + Task.FromResult(provider.GetUrl(picture.Id,picture.OriginalPath))); + } + + string? thumbnailPathUrl = null; + if (!string.IsNullOrEmpty(picture.ThumbnailPath)) + { + thumbnailPathUrl = await storageService.ExecuteAsync(picture.StorageModeId, provider => + Task.FromResult(provider.GetUrl(picture.Id,picture.ThumbnailPath))); + } + return new PictureResponse + { + Id = picture.Id, + Name = picture.Name, + Path = pathUrl, + OriginalPath = originalPathUrl, + ThumbnailPath = thumbnailPathUrl, + Description = picture.Description, + CreatedAt = picture.CreatedAt, + Tags = picture.Tags != null ? picture.Tags.Select(t => t.Name).ToList() : new List(), + TakenAt = picture.TakenAt, + ExifInfo = picture.ExifInfo ?? new ExifInfo(), + UserId = picture.UserId, + Username = picture.User?.UserName, + AlbumId = picture.AlbumId, + Permission = picture.Permission, + StorageModeName = picture.StorageMode?.Name + }; + } + public async Task> GetPicturesAsync( int page = 1, int pageSize = 8, @@ -103,18 +143,21 @@ public class PictureService( var picturesData = await dbContext.Pictures .Include(p => p.Tags) .Include(p => p.User) + .Include(p=>p.StorageMode) .Where(p => ids.Contains((ulong)p.Id)) .ToListAsync(); var picturesOrdered = ids .Select(id => picturesData.FirstOrDefault(p => p.Id == (int)id)) .Where(p => p != null) .ToList(); - var paginatedResults = picturesOrdered + var paginatedResultsTasks = picturesOrdered .Skip((page - 1) * pageSize) .Take(pageSize) - .Select(p => MapPictureToResponse(p!)) + .Select(async p => await MapPictureToResponseAsync(p!)) .ToList(); + var paginatedResults = (await Task.WhenAll(paginatedResultsTasks)).ToList(); + var totalCount = picturesOrdered.Count; await PopulateFavoriteInfo(dbContext, paginatedResults, userId); @@ -177,14 +220,16 @@ public class PictureService( // 获取分页数据 var picturesData = await query + .Include(x=>x.StorageMode) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); // 转换为响应格式 - var pictures = picturesData - .Select(p => MapPictureToResponse(p)) + var picturesTasks = picturesData + .Select(async p => await MapPictureToResponseAsync(p)) .ToList(); + var pictures = (await Task.WhenAll(picturesTasks)).ToList(); // 处理收藏信息 await PopulateFavoriteInfo(dbContext, pictures, userId); @@ -334,34 +379,6 @@ public class PictureService( }; } - // 将数据库实体映射到响应对象 - private PictureResponse MapPictureToResponse(Picture picture) - { - return new PictureResponse - { - Id = picture.Id, - Name = picture.Name, - Path = storageService.ExecuteAsync(picture.StorageType, provider => - Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))).Result, - OriginalPath = storageService.ExecuteAsync(picture.StorageType, provider => // Added OriginalPath - Task.FromResult(provider.GetUrl(picture.OriginalPath ?? string.Empty))).Result, - ThumbnailPath = !string.IsNullOrEmpty(picture.ThumbnailPath) ? - storageService.ExecuteAsync(picture.StorageType, provider => - Task.FromResult(provider.GetUrl(picture.ThumbnailPath))).Result - : null, // 如果没有缩略图路径,则为null - Description = picture.Description, - CreatedAt = picture.CreatedAt, - Tags = picture.Tags != null ? picture.Tags.Select(t => t.Name).ToList() : new List(), - TakenAt = picture.TakenAt, - ExifInfo = picture.ExifInfo ?? new ExifInfo(), - UserId = picture.UserId, - Username = picture.User?.UserName, - AlbumId = picture.AlbumId, - Permission = picture.Permission - // ProcessingStatus 字段已移除,客户端应通过 BackgroundTaskController 获取状态 - }; - } - // 填充收藏信息 private async Task PopulateFavoriteInfo(MyDbContext dbContext, List pictures, int? userId) { @@ -444,41 +461,60 @@ public class PictureService( int? userId, PermissionType permission = PermissionType.Public, int? albumId = null, - StorageType? storageType = null) + int? storageModeId = null) { - StorageType GetConfigStorageType(string configKey) + await using var dbContext = await contextFactory.CreateDbContextAsync(); + if (!storageModeId.HasValue) { - string? configValue = configuration[configKey]; - return !string.IsNullOrEmpty(configValue) && - Enum.TryParse(configValue, out var configStorageType) - ? configStorageType - : StorageType.Local; - } + string configKey = userId == null + ? "Storage:DefaultStorageModeId" + : "Storage:DefaultStorageModeId"; + string? defaultMode = configuration[configKey]; - if (userId == null) - { - storageType = GetConfigStorageType("Storage:AnonymousDefaultStorage"); - } - else if (storageType == null) - { - storageType = GetConfigStorageType("Storage:DefaultStorage"); - } - - ImageFormat convertToFormat = ImageFormat.Original; - string defaultFormatConfig = configuration["Upload:DefaultImageFormat"]; - if (!string.IsNullOrEmpty(defaultFormatConfig)) - { - if (Enum.TryParse(defaultFormatConfig, true, out var parsedFormat)) + if (string.IsNullOrEmpty(defaultMode)) { - convertToFormat = parsedFormat; + logger.LogError("未配置默认存储模式ID: {ConfigKey}", configKey); + throw new InvalidOperationException($"未配置默认存储模式: {configKey}"); + } + + var defaultStorageMode = await dbContext.Set() + .FirstOrDefaultAsync(sm => sm.Id == int.Parse(defaultMode) && sm.IsEnabled); + if (defaultStorageMode == null) + { + logger.LogError("根据名称 '{DefaultModeName}' 找不到已启用的默认存储模式。", defaultMode); + throw new InvalidOperationException($"找不到默认存储模式 '{defaultMode}'。"); + } + + storageModeId = defaultStorageMode.Id; + } + else + { + var specifiedMode = + await dbContext.Set().FirstOrDefaultAsync(sm => sm.Id == storageModeId.Value); + if (specifiedMode == null) + { + throw new ArgumentException($"找不到 ID 为 {storageModeId.Value} 的存储模式。"); + } + + if (!specifiedMode.IsEnabled) + { + throw new InvalidOperationException($"存储模式 '{specifiedMode.Name}' (ID: {storageModeId.Value}) 未启用。"); } } - int quality = 100; - string defaultQualityConfig = configuration["Upload:DefaultImageQuality"]; - if (!string.IsNullOrEmpty(defaultQualityConfig)) + ImageFormat convertToFormat = ImageFormat.WebP; + + // 高清图片压缩质量 + int quality = 100; // 默认值 + string hdQualityConfigKey = "Upload:HighQualityImageCompressionQuality"; + string? hdQualityConfig = configuration[hdQualityConfigKey]; + if (!string.IsNullOrEmpty(hdQualityConfig) && int.TryParse(hdQualityConfig, out int parsedHdQuality)) { - quality = int.Parse(defaultQualityConfig); + quality = Math.Clamp(parsedHdQuality, 50, 100); // 限制在 50-100 之间 + } + else + { + logger.LogWarning("配置项 '{ConfigKey}' 未找到或无效,使用默认压缩质量: {DefaultQuality}", hdQualityConfigKey, quality); } string baseName = Guid.NewGuid().ToString(); @@ -488,92 +524,104 @@ public class PictureService( string? tempOriginalLocalPath = null; string? tempConvertedHdLocalPath = null; string? tempThumbnailLocalPath = null; - - string storedOriginalPath; - string storedHdPath; + string? storedThumbnailPath = null; try { tempOriginalLocalPath = Path.GetTempFileName() + originalFileExtension; - File.Move(Path.GetTempFileName(), tempOriginalLocalPath); + File.Move(Path.GetTempFileName(), tempOriginalLocalPath); await using (var tempFileStream = new FileStream(tempOriginalLocalPath, FileMode.Create)) { await fileStream.CopyToAsync(tempFileStream); } - await using (var originalLocalStream = new FileStream(tempOriginalLocalPath, FileMode.Open, FileAccess.Read)) + string storedOriginalPath; + await using (var originalLocalStream = + new FileStream(tempOriginalLocalPath, FileMode.Open, FileAccess.Read)) { - storedOriginalPath = await storageService.ExecuteAsync(storageType.Value, + storedOriginalPath = await storageService.ExecuteAsync(storageModeId.Value, provider => provider.SaveAsync(originalLocalStream, originalStorageFileName, contentType)); } - string hdStorageFileName; - string hdContentType; string sourceForHdProcessing = tempOriginalLocalPath; - if (convertToFormat != ImageFormat.Original) - { - string convertedExtension = ImageHelper.GetFileExtensionFromFormat(convertToFormat); - hdStorageFileName = $"{baseName}-high-definition{convertedExtension}"; - hdContentType = ImageHelper.GetMimeTypeFromFormat(convertToFormat); - - tempConvertedHdLocalPath = Path.GetTempFileName() + convertedExtension; - File.Move(Path.GetTempFileName(), tempConvertedHdLocalPath); + string convertedExtension = ImageHelper.GetFileExtensionFromFormat(convertToFormat); + var hdStorageFileName = $"{baseName}-high-definition{convertedExtension}"; + var hdContentType = ImageHelper.GetMimeTypeFromFormat(convertToFormat); - await ImageHelper.ConvertImageFormatAsync(sourceForHdProcessing, tempConvertedHdLocalPath, convertToFormat, quality); - - await using var convertedHdStream = new FileStream(tempConvertedHdLocalPath, FileMode.Open, FileAccess.Read); - storedHdPath = await storageService.ExecuteAsync(storageType.Value, - provider => provider.SaveAsync(convertedHdStream, hdStorageFileName, hdContentType)); - } - else - { - hdStorageFileName = originalStorageFileName; // Same as original - hdContentType = contentType; - // No separate upload needed if it's the same as original; Path will point to the same stored object as OriginalPath. - // However, to ensure distinctness or if storage provider handles it, we can re-upload or copy. - // For simplicity, if no conversion, Path = OriginalPath. - storedHdPath = storedOriginalPath; - } + tempConvertedHdLocalPath = Path.GetTempFileName() + convertedExtension; + File.Move(Path.GetTempFileName(), tempConvertedHdLocalPath); - // 4. Generate and upload thumbnail (Picture.ThumbnailPath) - bool shouldGenerateThumbnailNow = userId.HasValue; + await ImageHelper.ConvertImageFormatAsync(sourceForHdProcessing, tempConvertedHdLocalPath, convertToFormat, + quality); + + await using var convertedHdStream = + new FileStream(tempConvertedHdLocalPath!, FileMode.Open, FileAccess.Read); + var storedHdPath = await storageService.ExecuteAsync(storageModeId.Value, + provider => provider.SaveAsync(convertedHdStream, hdStorageFileName!, hdContentType!)); + + bool shouldGenerateThumbnailNow = userId.HasValue; if (shouldGenerateThumbnailNow) { try { + // 缩略图最大宽度 + int thumbnailMaxWidth = 500; // 默认值 + string thumbnailMaxWidthConfigKey = "Upload:ThumbnailMaxWidth"; + string? thumbnailMaxWidthConfig = configuration[thumbnailMaxWidthConfigKey]; + if (!string.IsNullOrEmpty(thumbnailMaxWidthConfig) && int.TryParse(thumbnailMaxWidthConfig, out int parsedMaxWidth)) + { + thumbnailMaxWidth = Math.Max(100, parsedMaxWidth); // 最小宽度 100 + } + else + { + logger.LogWarning("配置项 '{ConfigKey}' 未找到或无效,使用默认缩略图最大宽度: {DefaultMaxWidth}", thumbnailMaxWidthConfigKey, thumbnailMaxWidth); + } + + // 缩略图压缩质量 + int thumbnailQuality = 75; // 默认值 + string thumbnailQualityConfigKey = "Upload:ThumbnailCompressionQuality"; + string? thumbnailQualityConfig = configuration[thumbnailQualityConfigKey]; + if (!string.IsNullOrEmpty(thumbnailQualityConfig) && int.TryParse(thumbnailQualityConfig, out int parsedThumbQuality)) + { + thumbnailQuality = Math.Clamp(parsedThumbQuality, 30, 90); // 限制在 30-90 之间 + } + else + { + logger.LogWarning("配置项 '{ConfigKey}' 未找到或无效,使用默认缩略图压缩质量: {DefaultThumbQuality}", thumbnailQualityConfigKey, thumbnailQuality); + } + tempThumbnailLocalPath = Path.GetTempFileName() + ".webp"; File.Move(Path.GetTempFileName(), tempThumbnailLocalPath); - - await ImageHelper.CreateThumbnailAsync(tempOriginalLocalPath, tempThumbnailLocalPath, 500); + await ImageHelper.CreateThumbnailAsync(tempOriginalLocalPath, tempThumbnailLocalPath, thumbnailMaxWidth, thumbnailQuality); string thumbnailUploadFileName = $"{baseName}-thumbnail.webp"; - await using var thumbnailFileStream = new FileStream(tempThumbnailLocalPath, FileMode.Open, FileAccess.Read); - storedThumbnailPath = await storageService.ExecuteAsync(storageType.Value, - provider => provider.SaveAsync(thumbnailFileStream, thumbnailUploadFileName, "image/webp")); + await using var thumbnailFileStream = + new FileStream(tempThumbnailLocalPath!, FileMode.Open, FileAccess.Read); + storedThumbnailPath = await storageService.ExecuteAsync(storageModeId.Value, + provider => provider.SaveAsync(thumbnailFileStream, thumbnailUploadFileName!, "image/webp")); } catch (Exception ex) { logger.LogError(ex, "生成和上传缩略图失败 during initial upload"); - // Continue without thumbnail if it fails here, background task can try later if needed } } - + string initialTitle = Path.GetFileNameWithoutExtension(fileName); string initialDescription = $"Uploaded on {DateTime.UtcNow}"; - await using var dbContext = await contextFactory.CreateDbContextAsync(); + await using var dbContextAsync = await contextFactory.CreateDbContextAsync(); User? user = null; if (userId is not null) { user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId); if (user == null) throw new Exception("找不到指定的用户"); } - Album? album = null; + if (albumId.HasValue) { - album = await dbContext.Albums.Include(a => a.User) + var album = await dbContext.Albums.Include(a => a.User) .FirstOrDefaultAsync(a => a.Id == albumId.Value); if (album == null) @@ -592,24 +640,21 @@ public class PictureService( { Name = initialTitle, Description = initialDescription, - OriginalPath = storedOriginalPath, // Store path to original uploaded file - Path = storedHdPath, // Store path to HD (possibly converted) file - ThumbnailPath = storedThumbnailPath, // Store path to thumbnail + OriginalPath = storedOriginalPath, + Path = storedHdPath, + ThumbnailPath = storedThumbnailPath, User = user, Permission = permission, AlbumId = albumId, - StorageType = storageType.Value, - // ProcessingStatus 等字段已移除 + StorageModeId = storageModeId.Value, }; dbContext.Pictures.Add(picture); await dbContext.SaveChangesAsync(); - if (userId != null) // Only queue for registered users + if (userId != null) { - // Pass OriginalPath for EXIF extraction and other initial processing await backgroundTaskQueue.QueuePictureProcessingTaskAsync(picture.Id, picture.OriginalPath); - var visualRecognitionPayload = new Background.Processors.VisualRecognitionPayload { PictureId = picture.Id, @@ -618,43 +663,50 @@ public class PictureService( await backgroundTaskQueue.QueueVisualRecognitionTaskAsync(visualRecognitionPayload); } - var pictureResponse = new PictureResponse - { - Id = picture.Id, - Name = picture.Name, - Path = await storageService.ExecuteAsync(picture.StorageType, provider => - Task.FromResult(provider.GetUrl(picture.Path))), - OriginalPath = await storageService.ExecuteAsync(picture.StorageType, provider => // Added OriginalPath - Task.FromResult(provider.GetUrl(picture.OriginalPath))), - ThumbnailPath = !string.IsNullOrEmpty(picture.ThumbnailPath) - ? await storageService.ExecuteAsync(picture.StorageType, provider => - Task.FromResult(provider.GetUrl(picture.ThumbnailPath))) - : null, - Description = picture.Description, - CreatedAt = picture.CreatedAt, - Tags = new List(), - Permission = permission, - AlbumId = albumId, - AlbumName = album?.Name, - // ProcessingStatus 字段已移除 - }; - + var pictureResponse = await MapPictureToResponseAsync(picture); return (pictureResponse, picture.Id); } finally { - // Clean up temporary local files if (!string.IsNullOrEmpty(tempOriginalLocalPath) && File.Exists(tempOriginalLocalPath)) { - try { File.Delete(tempOriginalLocalPath); } catch { /* ignored */ } + try + { + File.Delete(tempOriginalLocalPath); + Console.WriteLine(tempOriginalLocalPath); + } + catch + { + /* ignored */ + } } + if (!string.IsNullOrEmpty(tempConvertedHdLocalPath) && File.Exists(tempConvertedHdLocalPath)) { - try { File.Delete(tempConvertedHdLocalPath); } catch { /* ignored */ } + try + { + File.Delete(tempConvertedHdLocalPath); + Console.WriteLine(tempConvertedHdLocalPath); + } + catch + { + /* ignored */ + } } + if (!string.IsNullOrEmpty(tempThumbnailLocalPath) && File.Exists(tempThumbnailLocalPath)) { - try { File.Delete(tempThumbnailLocalPath); } catch { /* ignored */ } + try + { + File.Delete(tempThumbnailLocalPath); + Console.WriteLine(tempThumbnailLocalPath); + + + } + catch + { + /* ignored */ + } } } } @@ -693,9 +745,19 @@ public class PictureService( await using var dbContext = await contextFactory.CreateDbContextAsync(); + // 在查询时包含 StorageModeId var picturesToDelete = await dbContext.Pictures .Include(p => p.User) .Where(p => pictureIds.Contains(p.Id)) + .Select(p => new + { + p.Id, + p.Path, + p.ThumbnailPath, + p.OriginalPath, + UserId = p.User != null ? (int?)p.User.Id : null, + p.StorageModeId // 获取 StorageModeId + }) .ToListAsync(); var foundPictureIds = picturesToDelete.Select(p => p.Id).ToHashSet(); @@ -704,60 +766,63 @@ public class PictureService( results[id] = (false, "找不到此图片", null); } - var filesToDelete = - new List<(int PictureId, string Path, string? ThumbnailPath, string OriginalPath, int? UserId, StorageType StorageType)>(); - foreach (var picture in picturesToDelete) - { - filesToDelete.Add((picture.Id, picture.Path, picture.ThumbnailPath, picture.OriginalPath, picture.User?.Id, - picture.StorageType)); - } - + // 从数据库中删除记录 if (picturesToDelete.Any()) { - dbContext.Pictures.RemoveRange(picturesToDelete); - await dbContext.SaveChangesAsync(); + var idsToRemove = picturesToDelete.Select(p => p.Id).ToList(); + // EF Core 7+ 可以使用 ExecuteDeleteAsync + await dbContext.Pictures.Where(p => idsToRemove.Contains(p.Id)).ExecuteDeleteAsync(); + // 对于旧版本 EF Core: + // var entitiesToRemove = await dbContext.Pictures.Where(p => idsToRemove.Contains(p.Id)).ToListAsync(); + // dbContext.Pictures.RemoveRange(entitiesToRemove); + // await dbContext.SaveChangesAsync(); } - foreach (var (pictureId, path, thumbnailPath, originalPath, userId, storageType) in filesToDelete) + // 从存储中删除文件 + foreach (var picInfo in picturesToDelete) { try { string? errorMsg = null; + if (picInfo.StorageModeId < 0) + { + results[picInfo.Id] = (false, "图片记录缺少有效的StorageModeId,无法删除文件。", picInfo.UserId); + logger.LogWarning("图片 {PictureId} 缺少 StorageModeId,跳过文件删除。", picInfo.Id); + continue; + } try { - // Delete original file - if (!string.IsNullOrEmpty(originalPath)) + if (!string.IsNullOrEmpty(picInfo.OriginalPath)) { - await storageService.ExecuteAsync(storageType, - provider => provider.DeleteAsync(originalPath)); - } - - // Delete HD/processed file - if (!string.IsNullOrEmpty(path) && path != originalPath) // Avoid double delete if Path is same as OriginalPath - { - await storageService.ExecuteAsync(storageType, - provider => provider.DeleteAsync(path)); + await storageService.ExecuteAsync(picInfo.StorageModeId, + provider => provider.DeleteAsync(picInfo.OriginalPath)); } - // Delete thumbnail - if (!string.IsNullOrEmpty(thumbnailPath)) + if (!string.IsNullOrEmpty(picInfo.Path) && picInfo.Path != picInfo.OriginalPath) { - await storageService.ExecuteAsync(storageType, - provider => provider.DeleteAsync(thumbnailPath)); + await storageService.ExecuteAsync(picInfo.StorageModeId, + provider => provider.DeleteAsync(picInfo.Path)); + } + + if (!string.IsNullOrEmpty(picInfo.ThumbnailPath)) + { + await storageService.ExecuteAsync(picInfo.StorageModeId, + provider => provider.DeleteAsync(picInfo.ThumbnailPath)); } } catch (Exception ex) { errorMsg = $"数据库记录已删除,但删除文件失败: {ex.Message}"; - logger.LogError(ex, "删除图片文件时出错"); + logger.LogError(ex, "删除图片文件时出错 (ID: {PictureId})", picInfo.Id); } - results[pictureId] = (true, errorMsg, userId); + results[picInfo.Id] = (true, errorMsg, picInfo.UserId); } catch (Exception ex) { - results[pictureId] = (false, $"处理图片删除时出错: {ex.Message}", userId); + results[picInfo.Id] = (false, $"处理图片删除时出错: {ex.Message}", picInfo.UserId); + logger.LogError(ex, "处理图片删除的外部循环出错 (ID: {PictureId})", picInfo.Id); } } @@ -775,6 +840,7 @@ public class PictureService( var picture = await dbContext.Pictures .Include(p => p.User) .Include(p => p.Tags) + .Include(p => p.StorageMode) .FirstOrDefaultAsync(p => p.Id == pictureId); if (picture == null) @@ -840,25 +906,7 @@ public class PictureService( picture.UpdatedAt = DateTime.UtcNow; await dbContext.SaveChangesAsync(); - - var pictureResponse = new PictureResponse - { - Id = picture.Id, - Name = picture.Name, - Path = await storageService.ExecuteAsync(picture.StorageType, provider => - Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))), - OriginalPath = await storageService.ExecuteAsync(picture.StorageType, provider => // Added OriginalPath - Task.FromResult(provider.GetUrl(picture.OriginalPath ?? string.Empty))), - ThumbnailPath = !string.IsNullOrEmpty(picture.ThumbnailPath) ? - await storageService.ExecuteAsync(picture.StorageType, provider => Task.FromResult(provider.GetUrl(picture.ThumbnailPath))) - : null, - Description = picture.Description, - CreatedAt = picture.CreatedAt, - Tags = picture.Tags?.Select(t => t.Name).ToList() ?? new List(), - TakenAt = picture.TakenAt, - ExifInfo = picture.ExifInfo - }; - + var pictureResponse = await MapPictureToResponseAsync(picture); return (pictureResponse, userId); } @@ -921,4 +969,25 @@ public class PictureService( return await dbContext.Favorites .AnyAsync(f => f.PictureId == pictureId && f.User.Id == userId); } + + public async Task GetPictureByIdAsync(int pictureId) + { + await using var dbContext = await contextFactory.CreateDbContextAsync(); + var picture = await dbContext.Pictures + .Include(p => p.User) + .Include(p => p.Tags) + .Include(p => p.StorageMode) // 确保加载 StorageMode 以便 MapPictureToResponseAsync 正确工作 + .AsNoTracking() // 如果只是读取数据,使用 AsNoTracking 可以提高性能 + .FirstOrDefaultAsync(p => p.Id == pictureId); + + if (picture == null) + { + logger.LogWarning("GetPictureByIdAsync: Picture with ID {PictureId} not found.", pictureId); + return null; + } + + var pictureResponse = await MapPictureToResponseAsync(picture); + + return picture; + } } \ No newline at end of file diff --git a/Services/Storage/IStorageProvider.cs b/Services/Storage/IStorageProvider.cs index 5aace83..e06c13f 100644 --- a/Services/Storage/IStorageProvider.cs +++ b/Services/Storage/IStorageProvider.cs @@ -15,7 +15,7 @@ public interface IStorageProvider /// /// 获取文件URL /// - string GetUrl(string storagePath); + string GetUrl(int pictureId,string storagePath); /// /// 下载文件到本地临时目录 diff --git a/Services/Storage/IStorageService.cs b/Services/Storage/IStorageService.cs index f3f4b51..7ffa0a8 100644 --- a/Services/Storage/IStorageService.cs +++ b/Services/Storage/IStorageService.cs @@ -8,18 +8,18 @@ namespace Foxel.Services.Storage; public interface IStorageService { /// - /// 在指定存储类型上执行操作 + /// 在指定存储模式上执行操作 /// /// 操作结果类型 - /// 存储类型 + /// 存储模式的ID /// 要执行的操作 /// 操作结果 - Task ExecuteAsync(StorageType storageType, Func> operation); + Task ExecuteAsync(int storageModeId, Func> operation); /// - /// 在指定存储类型上执行无返回值的操作 + /// 在指定存储模式上执行无返回值的操作 /// - /// 存储类型 + /// 存储模式的ID /// 要执行的操作 - Task ExecuteAsync(StorageType storageType, Func operation); + Task ExecuteAsync(int storageModeId, Func operation); } diff --git a/Services/Storage/Providers/CosStorageProvider.cs b/Services/Storage/Providers/CosStorageProvider.cs index 00bf2d7..9e9dfcf 100644 --- a/Services/Storage/Providers/CosStorageProvider.cs +++ b/Services/Storage/Providers/CosStorageProvider.cs @@ -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 _logger; - public CustomQCloudCredentialProvider(IConfigService configService, ILogger logger) - : base(null, null, 0L, null) + public CustomQCloudCredentialProvider(CosStorageConfig config, ILogger 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 logger) : IStorageProvider +public class CosStorageProvider : IStorageProvider { + private readonly CosStorageConfig _cosConfig; + private readonly IConfigService _configService; // 保留用于可能的应用级配置 + private readonly ILogger _logger; + + public CosStorageProvider(CosStorageConfig cosConfig, IConfigService configService, ILogger 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() : Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); @@ -85,7 +117,7 @@ public class CosStorageProvider(IConfigService configService, ILogger 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 _logger; + + public LocalStorageProvider(LocalStorageConfig config, ILogger 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 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 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); } } \ No newline at end of file diff --git a/Services/Storage/Providers/S3StorageProvider.cs b/Services/Storage/Providers/S3StorageProvider.cs index eb64ef8..30fe089 100644 --- a/Services/Storage/Providers/S3StorageProvider.cs +++ b/Services/Storage/Providers/S3StorageProvider.cs @@ -7,16 +7,47 @@ using Microsoft.Extensions.Logging; namespace Foxel.Services.Storage.Providers; -[StorageProvider(StorageType.S3)] -public class S3StorageProvider(IConfigService configService, ILogger 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 _logger; + + public S3StorageProvider(S3StorageConfig s3Config, IConfigService configService, ILogger 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 logger) : IStorageProvider +public class TelegramStorageProvider(TelegramStorageConfig _telegramConfig, IConfigService configService, ILogger logger) : IStorageProvider { public async Task 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(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 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 _logger; + + public WebDavStorageProvider(WebDavStorageConfig webDavConfig, IConfigService configService, ILogger 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 _logger; private readonly Dictionary _storageProviders = new(); + private readonly IDbContextFactory _contextFactory; - public StorageService(IServiceProvider serviceProvider, ILogger logger) + public StorageService( + IServiceProvider serviceProvider, + ILogger logger, + IDbContextFactory 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() != 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] 特性并且位于扫描的程序集中。"); + } } /// - /// 获取指定存储类型的提供者实例 + /// 根据 StorageModeId 获取并配置提供者实例 /// - 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(jsonConfig) + ?? throw new JsonException($"无法反序列化 LocalStorageConfig。JSON: {jsonConfig}"); + case StorageType.Telegram: + return JsonSerializer.Deserialize(jsonConfig) + ?? throw new JsonException($"无法反序列化 TelegramStorageConfig。JSON: {jsonConfig}"); + case StorageType.S3: + return JsonSerializer.Deserialize(jsonConfig) + ?? throw new JsonException($"无法反序列化 S3StorageConfig。JSON: {jsonConfig}"); + case StorageType.Cos: + return JsonSerializer.Deserialize(jsonConfig) + ?? throw new JsonException($"无法反序列化 CosStorageConfig。JSON: {jsonConfig}"); + case StorageType.WebDAV: + return JsonSerializer.Deserialize(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); + } } /// - /// 在指定存储类型上执行操作 + /// 在指定存储模式上执行操作 /// - public async Task ExecuteAsync(StorageType storageType, Func> operation) + public async Task ExecuteAsync(int storageModeId, Func> operation) { - var provider = GetProvider(storageType); + var provider = GetProvider(storageModeId); return await operation(provider); } /// - /// 在指定存储类型上执行无返回值的操作 + /// 在指定存储模式上执行无返回值的操作 /// - public async Task ExecuteAsync(StorageType storageType, Func operation) + public async Task ExecuteAsync(int storageModeId, Func operation) { - var provider = GetProvider(storageType); + var provider = GetProvider(storageModeId); await operation(provider); } -} +} \ No newline at end of file diff --git a/Utils/ImageHelper.cs b/Utils/ImageHelper.cs index 3955fe8..935fab7 100644 --- a/Utils/ImageHelper.cs +++ b/Utils/ImageHelper.cs @@ -33,10 +33,10 @@ public static class ImageHelper /// /// 原始图片路径 /// 缩略图保存路径 - /// 缩略图宽度 + /// 缩略图最大宽度 /// 压缩质量(1-100) /// 生成的缩略图的文件大小(字节) - public static async Task CreateThumbnailAsync(string originalPath, string thumbnailPath, int width, + public static async Task CreateThumbnailAsync(string originalPath, string thumbnailPath, int maxWidth, int quality = 75) { // 获取原始文件大小 @@ -45,16 +45,14 @@ public static class ImageHelper using var image = await Image.LoadAsync(originalPath); - // 去除EXIF元数据以减小文件大小 image.Metadata.ExifProfile = null; image.Mutate(x => x.Resize(new ResizeOptions { - Size = new Size(width, 0), + Size = new Size(maxWidth, 0), Mode = ResizeMode.Max })); - // 强制使用 WebP 格式,修改缩略图路径扩展名 string webpThumbnailPath = Path.ChangeExtension(thumbnailPath, ".webp"); int adjustedQuality = AdjustQualityByFileSize(originalSize, ".webp", quality); @@ -386,28 +384,28 @@ public static class ImageHelper switch (targetFormat) { - case ImageFormat.Jpeg: - await convertedImage.SaveAsJpegAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder - { - Quality = quality - }); - break; + case ImageFormat.Jpeg: + await convertedImage.SaveAsJpegAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder + { + Quality = quality + }); + break; - case ImageFormat.Png: - await convertedImage.SaveAsPngAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Png.PngEncoder - { - CompressionLevel = SixLabors.ImageSharp.Formats.Png.PngCompressionLevel.BestCompression, - ColorType = SixLabors.ImageSharp.Formats.Png.PngColorType.RgbWithAlpha - }); - break; + case ImageFormat.Png: + await convertedImage.SaveAsPngAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Png.PngEncoder + { + CompressionLevel = SixLabors.ImageSharp.Formats.Png.PngCompressionLevel.BestCompression, + ColorType = SixLabors.ImageSharp.Formats.Png.PngColorType.RgbWithAlpha + }); + break; - case ImageFormat.WebP: - await convertedImage.SaveAsWebpAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Webp.WebpEncoder - { - Quality = quality, - Method = SixLabors.ImageSharp.Formats.Webp.WebpEncodingMethod.BestQuality - }); - break; + case ImageFormat.WebP: + await convertedImage.SaveAsWebpAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Webp.WebpEncoder + { + Quality = quality, + Method = SixLabors.ImageSharp.Formats.Webp.WebpEncodingMethod.BestQuality + }); + break; } } diff --git a/Web/src/api/fetchClient.ts b/Web/src/api/fetchClient.ts index c5a5331..a6b679c 100644 --- a/Web/src/api/fetchClient.ts +++ b/Web/src/api/fetchClient.ts @@ -20,6 +20,13 @@ export interface PaginatedResult { code: number; } +// 通用批量删除结果 +export interface BatchDeleteResult { + successCount: number; + failedCount: number; + failedIds?: number[]; +} + export const BASE_URL = import.meta.env.PROD ? '/api' : 'http://localhost:5153/api'; export async function fetchApi( diff --git a/Web/src/api/index.ts b/Web/src/api/index.ts index 24f1c9d..158685a 100644 --- a/Web/src/api/index.ts +++ b/Web/src/api/index.ts @@ -8,4 +8,5 @@ export * from './pictureApi'; export * from './pictureManagementApi'; export * from './tagApi'; export * from './userManagementApi'; -export * from './vectorDbApi'; \ No newline at end of file +export * from './vectorDbApi'; +export * from './storageManagementApi'; \ No newline at end of file diff --git a/Web/src/api/logManagementApi.ts b/Web/src/api/logManagementApi.ts index 2076db3..b88b7ca 100644 --- a/Web/src/api/logManagementApi.ts +++ b/Web/src/api/logManagementApi.ts @@ -1,5 +1,4 @@ -import { fetchApi, type BaseResult, type PaginatedResult } from './fetchClient'; -import { type BatchDeleteResult } from './userManagementApi'; +import { fetchApi, type BaseResult, type BatchDeleteResult, type PaginatedResult } from './fetchClient'; // 日志级别枚举 diff --git a/Web/src/api/pictureApi.ts b/Web/src/api/pictureApi.ts index 61ef0c5..951314b 100644 --- a/Web/src/api/pictureApi.ts +++ b/Web/src/api/pictureApi.ts @@ -40,6 +40,7 @@ export interface PictureResponse { permission: number; albumId?: number; albumName?: string; + storageModeName:string; } // 收藏请求 diff --git a/Web/src/api/pictureManagementApi.ts b/Web/src/api/pictureManagementApi.ts index c1d4b21..fb02cac 100644 --- a/Web/src/api/pictureManagementApi.ts +++ b/Web/src/api/pictureManagementApi.ts @@ -1,6 +1,5 @@ -import { fetchApi, type BaseResult, type PaginatedResult } from './fetchClient'; +import { fetchApi, type BaseResult, type BatchDeleteResult, type PaginatedResult } from './fetchClient'; import { type PictureResponse } from './pictureApi'; -import { type BatchDeleteResult } from './userManagementApi'; // 获取图片列表 diff --git a/Web/src/api/storageManagementApi.ts b/Web/src/api/storageManagementApi.ts new file mode 100644 index 0000000..58fda42 --- /dev/null +++ b/Web/src/api/storageManagementApi.ts @@ -0,0 +1,156 @@ +import { fetchApi, type BaseResult, type BatchDeleteResult, type PaginatedResult } from './fetchClient'; + +export enum StorageTypeEnum { + Local = 0, + Telegram = 1, + S3 = 2, + Cos = 3, + WebDAV = 4, +} +export const StorageTypeLabels: Record = { + [StorageTypeEnum.Local]: "本地存储", + [StorageTypeEnum.Telegram]: "Telegram", + [StorageTypeEnum.S3]: "S3 对象存储", + [StorageTypeEnum.Cos]: "腾讯云 COS", + [StorageTypeEnum.WebDAV]: "WebDAV", +}; + +export interface StorageModeResponse { + id: number; + name: string; + storageType: StorageTypeEnum; + storageTypeName: string; + configurationJson?: string; + isEnabled: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateStorageModeRequest { + name: string; + storageType: StorageTypeEnum; + configurationJson?: string; + isEnabled: boolean; +} + +export interface UpdateStorageModeRequest { + id: number; + name: string; + storageType: StorageTypeEnum; + configurationJson?: string; + isEnabled: boolean; +} + +export interface StorageTypeResponse { + value: StorageTypeEnum; + name: string; +} + +export interface StorageModeFilterRequest { + page?: number; + pageSize?: number; + searchQuery?: string; + storageType?: StorageTypeEnum; + isEnabled?: boolean; +} + +// 获取存储模式列表 +export const getStorageModes = async ( + filters: StorageModeFilterRequest = {} +): Promise> => { + const { page = 1, pageSize = 10, searchQuery, storageType, isEnabled } = filters; + + const params = new URLSearchParams({ + page: page.toString(), + pageSize: pageSize.toString(), + }); + + if (searchQuery) params.append('searchQuery', searchQuery); + if (storageType !== undefined) params.append('storageType', storageType.toString()); + if (isEnabled !== undefined) params.append('isEnabled', isEnabled.toString()); + + const response = await fetchApi(`/management/storage/get_modes?${params.toString()}`); + return response as PaginatedResult; +}; + +// 根据ID获取单个存储模式 +export const getStorageModeById = async (id: number): Promise> => { + return fetchApi( + `/management/storage/get_mode/${id}`, + { method: 'GET' } + ); +}; + +// 创建存储模式 +export const createStorageMode = async ( + data: CreateStorageModeRequest +): Promise> => { + return fetchApi( + '/management/storage/create_mode', + { + method: 'POST', + body: JSON.stringify(data) + } + ); +}; + +// 更新存储模式 +export const updateStorageMode = async ( + data: UpdateStorageModeRequest +): Promise> => { + return fetchApi( + '/management/storage/update_mode', + { + method: 'POST', + body: JSON.stringify(data) + } + ); +}; + +// 删除存储模式 +export const deleteStorageMode = async (id: number): Promise> => { + return fetchApi( + '/management/storage/delete_mode', + { + method: 'POST', + body: JSON.stringify(id) // 后端期望body中直接是id + } + ); +}; + +// 批量删除存储模式 +export const batchDeleteStorageModes = async ( + ids: number[] +): Promise> => { + return fetchApi( + '/management/storage/batch_delete_modes', + { + method: 'POST', + body: JSON.stringify(ids) + } + ); +}; + +// 获取所有可用的存储类型 +export const getStorageTypes = async (): Promise> => { + return fetchApi( + '/management/storage/get_storage_types', + { method: 'GET' } + ); +}; + +// 获取默认存储模式ID +export const getDefaultStorageModeId = async (): Promise> => { + return fetchApi( + '/management/storage/get_default_mode_id', + { method: 'GET' } + ); +}; + +// 设置默认存储模式 +export const setDefaultStorageMode = async (id: number): Promise> => { + return fetchApi( + `/management/storage/set_default_mode/${id}`, + { method: 'POST' } + ); +}; diff --git a/Web/src/api/userManagementApi.ts b/Web/src/api/userManagementApi.ts index 37a07aa..4463a38 100644 --- a/Web/src/api/userManagementApi.ts +++ b/Web/src/api/userManagementApi.ts @@ -1,4 +1,4 @@ -import { fetchApi, type BaseResult, type PaginatedResult } from './fetchClient'; +import { fetchApi, type BaseResult, type BatchDeleteResult, type PaginatedResult } from './fetchClient'; export type UserRole = "Administrator" | "User" | ""; @@ -34,13 +34,6 @@ export interface AdminUpdateUserRequest { role?: string; } -// 批量删除结果 -export interface BatchDeleteResult { - successCount: number; - failedCount: number; - failedIds?: number[]; -} - // 用户筛选请求参数 export interface UserFilterRequest { page?: number; diff --git a/Web/src/components/image/ImageGrid/ImageCard.tsx b/Web/src/components/image/ImageGrid/ImageCard.tsx index 8f7b7b3..63f08e5 100644 --- a/Web/src/components/image/ImageGrid/ImageCard.tsx +++ b/Web/src/components/image/ImageGrid/ImageCard.tsx @@ -91,10 +91,17 @@ const ImageCard: React.FC = ({ {!selectable && ( <>
-
- {permissionTypeMap[image.permission]?.icon} {permissionTypeMap[image.permission]?.label || '公开'} +
+
+ {permissionTypeMap[image.permission]?.icon} {permissionTypeMap[image.permission]?.label || '公开'} +
+ {image.storageModeName && ( +
+ {image.storageModeName} +
+ )}
diff --git a/Web/src/components/image/ImageGrid/ImageGrid.css b/Web/src/components/image/ImageGrid/ImageGrid.css index 9180e26..a0911bc 100644 --- a/Web/src/components/image/ImageGrid/ImageGrid.css +++ b/Web/src/components/image/ImageGrid/ImageGrid.css @@ -121,12 +121,19 @@ right: 0; display: flex; justify-content: space-between; + align-items: center; /* 确保垂直对齐 */ padding: 0 8px; opacity: 0; transition: opacity 0.35s ease; z-index: 2; } +.custom-card-left-indicators { /* 新增样式 */ + display: flex; + align-items: center; + gap: 6px; /* 指示器之间的间距 */ +} + .custom-card:hover .custom-card-indicators { opacity: 1; } @@ -145,6 +152,20 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } +.custom-card-storage-mode { /* 新增样式 */ + background-color: rgba(0, 0, 0, 0.6); + color: white; + border-radius: 12px; + padding: 3px 8px; + font-size: 10px; + font-weight: 500; + display: flex; + align-items: center; + gap: 3px; + backdrop-filter: blur(4px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + .custom-card-metadata { background-color: rgba(0, 0, 0, 0.6); color: white; diff --git a/Web/src/pages/admin/storage/StorageManagement.tsx b/Web/src/pages/admin/storage/StorageManagement.tsx new file mode 100644 index 0000000..43d438c --- /dev/null +++ b/Web/src/pages/admin/storage/StorageManagement.tsx @@ -0,0 +1,571 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Table, Button, Card, Input, Space, Modal, Form, message, Tag, Typography, Popconfirm, Row, Col, Select, Switch, Tooltip, Alert} from 'antd'; +import { + DeleteOutlined, EditOutlined, SearchOutlined, ExclamationCircleOutlined, ReloadOutlined, + PlusOutlined, DatabaseOutlined, FilterOutlined, ClearOutlined, StarOutlined, StarFilled +} from '@ant-design/icons'; +import { + getStorageModes, deleteStorageMode, createStorageMode, updateStorageMode, batchDeleteStorageModes, getStorageTypes, + getDefaultStorageModeId, setDefaultStorageMode, + type StorageModeResponse, type CreateStorageModeRequest, type UpdateStorageModeRequest, type StorageModeFilterRequest, + type StorageTypeResponse, StorageTypeEnum, StorageTypeLabels +} from '../../../api'; +import { useOutletContext } from 'react-router'; +import type { Breakpoint } from 'antd'; + +const { Title, Text } = Typography; +const { Option } = Select; +const { confirm } = Modal; + +const StorageManagementPage: React.FC = () => { + const { isMobile } = useOutletContext<{ isMobile: boolean }>(); + + const [storageModes, setStorageModes] = useState([]); + const [availableStorageTypes, setAvailableStorageTypes] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [defaultStorageModeId, setDefaultStorageModeId] = useState(null); + const [, setIsLoadingDefault] = useState(false); + const [settingDefaultModeId, setSettingDefaultModeId] = useState(null); + + const [filters, setFilters] = useState({}); + const [showFilters, setShowFilters] = useState(false); + const [filterForm] = Form.useForm(); + + const [isModalVisible, setIsModalVisible] = useState(false); + const [modalTitle, setModalTitle] = useState(''); + const [editingMode, setEditingMode] = useState(null); + const [form] = Form.useForm(); + const [currentStorageTypeForHelp, setCurrentStorageTypeForHelp] = useState(null); + + const fetchStorageTypes = useCallback(async () => { + try { + const response = await getStorageTypes(); + if (response.success && response.data) { + setAvailableStorageTypes(response.data); + } else { + message.error(response.message || '获取存储类型失败'); + } + } catch (error) { + message.error('获取存储类型失败,请检查网络'); + } + }, []); + + const fetchDefaultStorageModeId = useCallback(async () => { + try { + setIsLoadingDefault(true); + const response = await getDefaultStorageModeId(); + if (response.success) { + setDefaultStorageModeId(response.data ?? null); + } else { + message.error(response.message || '获取默认存储模式失败'); + } + } catch (error) { + message.error('获取默认存储模式失败,请检查网络'); + } finally { + setIsLoadingDefault(false); + } + }, []); + + const fetchStorageModes = useCallback(async (page = currentPage, size = pageSize, filterParams = filters) => { + setLoading(true); + try { + const response = await getStorageModes({ + page, + pageSize: size, + ...filterParams + }); + if (response.success && response.data) { + setStorageModes(response.data.map(m => ({...m, createdAt: new Date(m.createdAt), updatedAt: new Date(m.updatedAt) }))); + setTotal(response.totalCount || 0); + } else { + message.error(response.message || '获取存储模式列表失败'); + } + } catch (error) { + message.error('获取存储模式列表失败,请检查网络连接'); + } finally { + setLoading(false); + } + }, [currentPage, pageSize, filters]); + + useEffect(() => { + fetchStorageTypes(); + fetchStorageModes(); + fetchDefaultStorageModeId(); + }, [fetchStorageModes, fetchStorageTypes, fetchDefaultStorageModeId]); + + const handlePageChange = (page: number, size?: number) => { + setCurrentPage(page); + if (size) setPageSize(size); + fetchStorageModes(page, size || pageSize, filters); + }; + + const handleFilter = async () => { + const values = await filterForm.validateFields(); + const newFilters: StorageModeFilterRequest = { + searchQuery: values.searchQuery, + storageType: values.storageType, + isEnabled: typeof values.isEnabled === 'boolean' ? values.isEnabled : undefined, + }; + setFilters(newFilters); + setCurrentPage(1); + fetchStorageModes(1, pageSize, newFilters); + }; + + const handleClearFilters = () => { + filterForm.resetFields(); + setFilters({}); + setCurrentPage(1); + fetchStorageModes(1, pageSize, {}); + }; + + const handleQuickSearch = (searchQuery: string) => { + const newFilters = { ...filters, searchQuery }; + setFilters(newFilters); + setCurrentPage(1); + fetchStorageModes(1, pageSize, newFilters); + }; + + const showCreateModal = () => { + setModalTitle('创建新存储模式'); + setEditingMode(null); + setCurrentStorageTypeForHelp(null); + form.resetFields(); // This will clear all fields, including any 'configuration' fields + form.setFieldsValue({ isEnabled: true }); + setIsModalVisible(true); + }; + + const showEditModal = (mode: StorageModeResponse) => { + setModalTitle('编辑存储模式'); + setEditingMode(mode); + setCurrentStorageTypeForHelp(mode.storageType); + + let parsedConfig = {}; + if (mode.configurationJson) { + try { + parsedConfig = JSON.parse(mode.configurationJson); + } catch (e) { + message.error('解析现有配置JSON失败,请检查数据格式。'); + parsedConfig = {}; + } + } + + form.setFieldsValue({ + name: mode.name, + storageType: mode.storageType, + isEnabled: mode.isEnabled, + configuration: parsedConfig, + }); + setIsModalVisible(true); + }; + + const handleModalOk = async () => { + try { + const values = await form.validateFields(); + const { name, storageType, isEnabled, configuration } = values; + const configToSave = configuration || {}; + const commonData = { + name, + storageType, + configurationJson: JSON.stringify(configToSave), + isEnabled, + }; + + let response; + if (editingMode) { + const updateData: UpdateStorageModeRequest = { id: editingMode.id, ...commonData }; + response = await updateStorageMode(updateData); + } else { + const createData: CreateStorageModeRequest = commonData; + response = await createStorageMode(createData); + } + + if (response.success) { + message.success(editingMode ? '存储模式更新成功' : '存储模式创建成功'); + fetchStorageModes(editingMode ? currentPage : 1); + setIsModalVisible(false); + } else { + message.error(response.message || (editingMode ? '更新失败' : '创建失败')); + } + } catch (errorInfo) { + console.error('Form validation failed:', errorInfo); + message.error('请检查表单输入。'); + } + }; + + const handleDelete = async (id: number) => { + try { + const response = await deleteStorageMode(id); + if (response.success) { + message.success('存储模式删除成功'); + fetchStorageModes(); // Refresh + } else { + message.error(response.message || '删除失败'); + } + } catch (error) { + message.error('删除失败,请检查网络'); + } + }; + + const handleBatchDelete = async () => { + if (selectedRowKeys.length === 0) { + message.warning('请选择要删除的存储模式'); + return; + } + confirm({ + title: `确定要删除 ${selectedRowKeys.length} 个存储模式吗?`, + icon: , + content: '此操作不可逆。如果存储模式仍被图片使用,则无法删除。', + okText: '确认', + okType: 'danger', + cancelText: '取消', + async onOk() { + try { + const response = await batchDeleteStorageModes(selectedRowKeys as number[]); + if (response.success && response.data) { + message.success(`成功删除 ${response.data.successCount} 个存储模式`); + if (response.data.failedCount > 0) { + message.warning(`${response.data.failedCount} 个存储模式删除失败 (可能仍在使用中)。失败ID: ${response.data.failedIds?.join(', ')}`); + } + setSelectedRowKeys([]); + fetchStorageModes(); // Refresh + } else { + message.error(response.message || '批量删除失败'); + } + } catch (error) { + message.error('批量删除失败,请检查网络'); + } + } + }); + }; + + const handleSetDefault = async (id: number) => { + try { + setSettingDefaultModeId(id); // 开始为此特定项目设置默认 + const response = await setDefaultStorageMode(id); + if (response.success) { + message.success('默认存储模式设置成功'); + setDefaultStorageModeId(id); + // 可选: 如果默认状态会影响列表显示方式(除了星星图标),则重新获取列表 + // fetchStorageModes(currentPage, pageSize, filters); + } else { + message.error(response.message || '设置默认存储模式失败'); + } + } catch (error) { + message.error('设置默认存储模式失败,请检查网络'); + } finally { + setSettingDefaultModeId(null); // 清除此特定项目的加载状态 + } + }; + + const renderDynamicConfigFields = (storageType: StorageTypeEnum | null) => { + if (storageType === null || storageType === undefined) return null; + switch (storageType) { + case StorageTypeEnum.Local: + return ( + <> + + + + + + + + + + + ); + case StorageTypeEnum.Telegram: + return ( + <> + + + + + + + + + + + + + + ); + case StorageTypeEnum.S3: + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + ); + case StorageTypeEnum.Cos: + return ( + <> + + + + + + + + + + + + + + + + + + + + ); + case StorageTypeEnum.WebDAV: + return ( + <> + + + + + + + + + + + + + + + + + ); + default: + return ; + } + }; + + const columns = [ + { title: 'ID', dataIndex: 'id', key: 'id', responsive: ['md'] as Breakpoint[] }, + { + title: '名称', + dataIndex: 'name', + key: 'name', + render: (name: string, record: StorageModeResponse) => ( + + {record.id === defaultStorageModeId && ( + + + + )} + {name} + + ) + }, + { + title: '类型', dataIndex: 'storageType', key: 'storageType', + render: (type: StorageTypeEnum) => {StorageTypeLabels[type] || type}, + }, + { + title: '配置 (JSON)', dataIndex: 'configurationJson', key: 'configurationJson', responsive: ['lg'] as Breakpoint[], + render: (json?: string) => json ? {json} : , + }, + { + title: '启用状态', dataIndex: 'isEnabled', key: 'isEnabled', + render: (enabled: boolean) => {enabled ? '已启用' : '已禁用'}, + }, + { title: '更新时间', dataIndex: 'updatedAt', key: 'updatedAt', responsive: ['lg'] as Breakpoint[], render: (date: Date) => date.toLocaleString() }, + { + title: '操作', key: 'action', + render: (_: any, record: StorageModeResponse) => ( + + {record.isEnabled && record.id !== defaultStorageModeId && ( + + )} + + handleDelete(record.id)} + okText="确定" + cancelText="取消" + disabled={record.id === defaultStorageModeId} + > + + + + ), + }, + ]; + + return ( +
+ + + + + 存储模式管理 + + + 管理系统中的各种文件存储方式及其配置 + + + + {defaultStorageModeId && ( + + + 当前默认存储模式ID: {defaultStorageModeId} + + } + style={{ padding: '4px 12px' }} + /> + )} + {!defaultStorageModeId && ( + + )} + + + + + + + + + + + + + + + } onSearch={handleQuickSearch} /> + + + + {showFilters && ( + +
+ + + + + + + + + + + + + + + +
+
+ )} + + `共 ${t} 条记录`, + }} + rowSelection={{ selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys) }} + size={isMobile ? "small" : "middle"} + scroll={{ x: 'max-content' }} + /> + + + setIsModalVisible(false)} + okText={editingMode ? "更新" : "创建"} + cancelText="取消" + width={isMobile ? '90%' : 700} + > +
+ + + + + + + + + {renderDynamicConfigFields(currentStorageTypeForHelp)} + + + + + +
+ + ); +}; + +export default StorageManagementPage; diff --git a/Web/src/pages/admin/system/ConfigTabs.tsx b/Web/src/pages/admin/system/ConfigTabs.tsx index dddea1d..e8c6c10 100644 --- a/Web/src/pages/admin/system/ConfigTabs.tsx +++ b/Web/src/pages/admin/system/ConfigTabs.tsx @@ -1,16 +1,16 @@ import React from 'react'; -import { Tabs, Form, Input, Button, Select, Space, Divider, Typography } from 'antd'; +import { Tabs, Form, Input, Button, Space, Divider, Slider, InputNumber } from 'antd'; // InputNumber added import { ApiOutlined, RocketOutlined, PictureOutlined, SaveOutlined, SafetyCertificateOutlined, LockOutlined, GlobalOutlined, SettingOutlined, - CloudServerOutlined, DatabaseOutlined, UploadOutlined} from '@ant-design/icons'; + DatabaseOutlined, UploadOutlined +} from '@ant-design/icons'; import ConfigFormItem from './ConfigFormItem'; import ConfigSection from './ConfigSection'; -import VectorDbConfig from './VectorDbConfig'; +import VectorDbConfig from './VectorDbConfig'; const { TabPane } = Tabs; -const { Option } = Select; -const { Title, Paragraph } = Typography; +// const { Option } = Select; // Removed interface ConfigStructure { [key: string]: { @@ -24,17 +24,13 @@ interface ConfigTabsProps { isMobile: boolean; activeKey: string; onTabChange: (key: string) => void; - storageType: string; - onStorageTypeChange: (type: string) => void; formsMap: Record; allDescriptions: Record>; onSaveSingleConfig: (formInstance: any, groupName: string, key: string) => Promise; onSaveAllForGroup: (formInstance: any, groupName: string, itemKeys: string[]) => Promise; onBaseSaveConfig: (group: string, key: string, value: string) => Promise; setConfigs: React.Dispatch>; - storageOptions: Array<{ value: string; label: string; icon: React.ReactNode; }>; - imageFormatOptions: Array<{ value: string; label: string; description: string; }>; - imageQualityOptions: Array<{ value: string; label: string; description: string; }>; + // imageQualityOptions: Array<{ value: string; label: string; description: string; }>; // Removed } const ConfigTabs: React.FC = ({ @@ -43,17 +39,13 @@ const ConfigTabs: React.FC = ({ isMobile, activeKey, onTabChange, - storageType, - onStorageTypeChange, formsMap, allDescriptions, onSaveSingleConfig, onSaveAllForGroup, onBaseSaveConfig, setConfigs, - storageOptions, - imageFormatOptions, - imageQualityOptions, + // imageQualityOptions, // Removed }) => { const renderConfigFormItems = (formInstance: any, groupName: string, itemKeys: string[]) => { @@ -306,245 +298,93 @@ const ConfigTabs: React.FC = ({ ) }, { - key: 'Storage', - label: '存储设置', - icon: , + key: 'Upload', + label: '上传设置', + icon: , children: ( <> } - description="配置系统默认使用的文件存储方式" - isMobile={isMobile} - > -
-
-
- 登录用户默认存储 -
- -
- {allDescriptions.Storage?.DefaultStorage} -
-
-
-
- 匿名用户默认存储 -
- -
- {allDescriptions.Storage?.AnonymousDefaultStorage} -
-
-
-
- - } description="配置文件上传处理方式和图片转换参数" isMobile={isMobile} >
- 默认图片格式 + 缩略图最大宽度 (px)
- + />
- {allDescriptions.Upload?.DefaultImageFormat} + {allDescriptions.Upload?.ThumbnailMaxWidth}
+
- 默认压缩质量 + 缩略图压缩质量
- -
- {allDescriptions.Upload?.DefaultImageQuality} + onBaseSaveConfig('Upload', 'ThumbnailCompressionQuality', value.toString())} + style={{ margin: isMobile ? '0 5px' : '0 10px' }} + tooltip={{ + formatter: value => `${value}%` + }} + marks={{ + 30: '30%', + 60: '60%', + 90: '90%' + }} + /> +
+ {allDescriptions.Upload?.ThumbnailCompressionQuality}
-
- - } - description="配置各种外部存储服务的连接参数" - isMobile={isMobile} - > -
-
- 选择要配置的存储服务 -
- -
- 选择后将显示对应存储服务的详细配置选项 -
-
- -
- {storageType === 'Local' && ( -
- - 本地存储无需额外配置 - 文件将直接存储在服务器的本地文件系统中 +
+
+ 高清图片压缩质量
- )} - {storageType === 'Telegram' && ( -
- {renderConfigFormItems(formsMap.TelegramStorage, "Storage", ["TelegramStorageBotToken", "TelegramStorageChatId", "TelegramProxyAddress", "TelegramProxyPort", "TelegramProxyUsername", "TelegramProxyPassword"])} - - - - - - )} - {storageType === 'S3' && ( -
- {renderConfigFormItems(formsMap.S3Storage, "Storage", ["S3StorageAccessKey", "S3StorageSecretKey", "S3StorageBucketName", "S3StorageRegion", "S3StorageEndpoint", "S3StorageCdnUrl", "S3StorageUsePathStyleUrls"])} - - - - - - )} - {storageType === 'Cos' && ( -
- {renderConfigFormItems(formsMap.CosStorage, "Storage", ["CosStorageSecretId", "CosStorageSecretKey", "CosStorageToken", "CosStorageBucketName", "CosStorageRegion", "CosStorageCdnUrl"])} - - - - - - )} - {storageType === 'WebDAV' && ( -
- {renderConfigFormItems(formsMap.WebDAVStorage, "Storage", ["WebDAVServerUrl", "WebDAVUserName", "WebDAVPassword", "WebDAVBasePath", "WebDAVPublicUrl"])} - - - - - - )} + onBaseSaveConfig('Upload', 'HighQualityImageCompressionQuality', value.toString())} + style={{ margin: isMobile ? '0 5px' : '0 10px' }} + tooltip={{ + formatter: value => `${value}%` + }} + marks={{ + 50: '50%', + 75: '75%', + 100: '100%' + }} + /> +
+ {allDescriptions.Upload?.HighQualityImageCompressionQuality} +
+
diff --git a/Web/src/pages/admin/system/Index.tsx b/Web/src/pages/admin/system/Index.tsx index daa8121..c14715c 100644 --- a/Web/src/pages/admin/system/Index.tsx +++ b/Web/src/pages/admin/system/Index.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useState, useRef, useCallback } from 'react'; import { Card, message, Spin, Button, Upload, Modal, Space, Tooltip, Form, Typography, notification } from 'antd'; import { - CloudOutlined, DatabaseOutlined, CloudServerOutlined, GlobalOutlined, DownloadOutlined, UploadOutlined, QuestionCircleOutlined, SettingOutlined, CheckCircleOutlined @@ -46,37 +45,10 @@ const allDescriptions: Record> = { ServerUrl: '服务器URL', MaxConcurrentTasks: '后台任务最大并发处理数量 (例如: 图像分析、标签生成等)' }, - Storage: { - DefaultStorage: '已登录用户上传文件时的默认存储位置', - AnonymousDefaultStorage: '未登录用户上传文件时的默认存储位置', - TelegramStorageBotToken: 'Telegram 机器人令牌', - TelegramStorageChatId: 'Telegram 聊天ID', - TelegramProxyAddress: '代理服务器地址 (例如: 127.0.0.1)', - TelegramProxyPort: '代理服务器端口 (例如: 1080)', - TelegramProxyUsername: '代理用户名 (可选)', - TelegramProxyPassword: '代理密码 (可选)', - S3StorageAccessKey: 'S3访问密钥', - S3StorageSecretKey: 'S3私有密钥', - S3StorageBucketName: 'S3存储桶名称', - S3StorageRegion: 'S3区域 (例如:us-east-1)', - S3StorageEndpoint: 'S3端点URL (可选,默认为AWS S3)', - S3StorageCdnUrl: 'CDN URL (可选,用于加速文件访问)', - S3StorageUsePathStyleUrls: '使用路径形式URLs (true/false,兼容非AWS服务)', - CosStorageSecretId: '腾讯云COS密钥ID', - CosStorageSecretKey: '腾讯云COS私有密钥', - CosStorageToken: '腾讯云COS临时令牌(可选)', - CosStorageBucketName: 'COS存储桶名称', - CosStorageRegion: 'COS区域 (例如:ap-shanghai)', - CosStorageCdnUrl: 'CDN URL (可选,用于加速文件访问)', - WebDAVServerUrl: 'WebDAV 服务器 URL (例如: https://dav.example.com)', - WebDAVUserName: 'WebDAV 用户名', - WebDAVPassword: 'WebDAV 密码', - WebDAVBasePath: 'WebDAV 基础路径 (例如: files/upload)', - WebDAVPublicUrl: 'WebDAV 公共访问 URL (可选,用于文件访问)', - }, Upload: { - DefaultImageFormat: '上传图片时的默认处理格式,选择合适的格式可以优化存储和显示', - DefaultImageQuality: '适用于JPEG和WebP格式的图片质量设置,越高图片质量越好但文件越大' + HighQualityImageCompressionQuality: '高清图片的压缩质量,越高图片质量越好但文件越大。范围 50-100。', + ThumbnailMaxWidth: '缩略图的最大宽度(像素),例如设置为 400。', + ThumbnailCompressionQuality: '缩略图的压缩质量,用于平衡文件大小和清晰度。范围 30-90。' } }; @@ -86,7 +58,6 @@ const System: React.FC = () => { const [loading, setLoading] = useState(true); const [configs, setConfigs] = useState({}); const [activeKey, setActiveKey] = useState('AI'); - const [storageType, setStorageType] = useState('Telegram'); const [backupLoading, setBackupLoading] = useState(false); const [restoreLoading, setRestoreLoading] = useState(false); const [restoreModalVisible, setRestoreModalVisible] = useState(false); @@ -100,10 +71,6 @@ const System: React.FC = () => { const [jwtForm] = Form.useForm(); const [authForm] = Form.useForm(); const [appSettingsForm] = Form.useForm(); - const [telegramForm] = Form.useForm(); - const [s3Form] = Form.useForm(); - const [cosForm] = Form.useForm(); - const [webDAVForm] = Form.useForm(); const [uploadForm] = Form.useForm(); const formsMap: Record = { @@ -111,10 +78,6 @@ const System: React.FC = () => { Jwt: jwtForm, Authentication: authForm, AppSettings: appSettingsForm, - TelegramStorage: telegramForm, - S3Storage: s3Form, - CosStorage: cosForm, - WebDAVStorage: webDAVForm, Upload: uploadForm, }; @@ -145,20 +108,11 @@ const System: React.FC = () => { setConfigs(configGroups); setSecretFields(secretFieldsMap); - if (configGroups.Storage?.DefaultStorage) { - setStorageType(configGroups.Storage.DefaultStorage); - } - // 更高效地更新表单值 Object.keys(configGroups).forEach(group => { let formInstanceKey = group; - if (group === "Storage") { - } else if (group.endsWith("Storage") && formsMap[group]) { - formInstanceKey = group; - } - - const formInstance = formsMap[formInstanceKey] || (group === "Storage" ? formsMap[`${configGroups.Storage.DefaultStorage}Storage`] : null); + const formInstance = formsMap[formInstanceKey]; if (formInstance) { const initialGroupValues: Record = {}; @@ -456,34 +410,6 @@ const System: React.FC = () => { } }; - // 存储类型选项 - const storageOptions = [ - { value: 'Local', label: '本地存储', icon: }, - { value: 'Telegram', label: 'Telegram 频道', icon: }, - { value: 'S3', label: '亚马逊 S3', icon: }, - { value: 'Cos', label: '腾讯云 COS', icon: }, - { value: 'WebDAV', label: 'WebDAV 存储', icon: }, - ]; - - // 上传格式选项 - const imageFormatOptions = [ - { value: 'Original', label: '保持原始格式', description: '不改变原始图片格式' }, - { value: 'Jpeg', label: '转换为JPEG', description: '适合照片,文件较小但有损压缩' }, - { value: 'Png', label: '转换为PNG', description: '适合图形,无损但文件较大' }, - { value: 'Webp', label: '转换为WebP', description: '现代格式,体积小且质量好' }, - ]; - - // 图片质量选项 - const imageQualityOptions = [ - { value: '100', label: '100% - 最高质量', description: '无损压缩,文件较大' }, - { value: '95', label: '95% - 高质量', description: '几乎无损,推荐用于高质量需求' }, - { value: '90', label: '90% - 优质', description: '良好平衡,推荐一般用途' }, - { value: '85', label: '85% - 良好', description: '适合网页展示,节省空间' }, - { value: '80', label: '80% - 节省空间', description: '明显压缩但质量可接受' }, - { value: '75', label: '75% - 平衡', description: '显著减小文件大小' }, - { value: '70', label: '70% - 压缩', description: '最大压缩,质量较低' }, - ]; - useEffect(() => { fetchConfigs(); }, []); @@ -546,17 +472,12 @@ const System: React.FC = () => { isMobile={isMobile} activeKey={activeKey} onTabChange={setActiveKey} - storageType={storageType} - onStorageTypeChange={setStorageType} formsMap={formsMap} allDescriptions={allDescriptions} onSaveSingleConfig={handleSaveSingleConfig} onSaveAllForGroup={handleSaveAllForGroup} onBaseSaveConfig={baseSaveConfig} setConfigs={setConfigs} - storageOptions={storageOptions} - imageFormatOptions={imageFormatOptions} - imageQualityOptions={imageQualityOptions} /> )} diff --git a/Web/src/routes/index.tsx b/Web/src/routes/index.tsx index 6f54a3c..e4c13f5 100644 --- a/Web/src/routes/index.tsx +++ b/Web/src/routes/index.tsx @@ -24,6 +24,7 @@ import UserManagement from '../pages/admin/users/Index'; import PictureManagement from '../pages/admin/pictures/Index'; import UserDetail from '../pages/admin/users/UserDetail'; import AdminLogManagement from '../pages/admin/log/Index'; +import StorageManagementPage from '../pages/admin/storage/StorageManagement'; export interface RouteConfig { path: string; @@ -182,6 +183,17 @@ const routes: RouteConfig[] = [ breadcrumb: { title: '日志中心' } + }, + { + path: 'storage', + key: 'admin-storage', + icon: , + label: '存储配置', + element: , + area: 'admin', + breadcrumb: { + title: '存储配置' + } }, { path: 'system',