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

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

View File

@@ -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<ActionResult<PaginatedResult<StorageModeResponse>>> 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<StorageModeResponse>($"Failed to get storage modes: {ex.Message}", 500);
}
}
[HttpGet("get_mode/{id}")]
public async Task<ActionResult<BaseResult<StorageModeResponse>>> GetStorageModeById(int id)
{
try
{
var result = await storageManagementService.GetStorageModeByIdAsync(id);
return Success(result, "Storage mode retrieved successfully.");
}
catch (KeyNotFoundException ex)
{
return Error<StorageModeResponse>(ex.Message, 404);
}
catch (Exception ex)
{
return Error<StorageModeResponse>($"Failed to get storage mode: {ex.Message}", 500);
}
}
[HttpPost("create_mode")]
public async Task<ActionResult<BaseResult<StorageModeResponse>>> CreateStorageMode(
[FromBody] CreateStorageModeRequest request)
{
try
{
var result = await storageManagementService.CreateStorageModeAsync(request);
return Success(result, "Storage mode created successfully.");
}
catch (ArgumentException ex)
{
return Error<StorageModeResponse>(ex.Message, 400);
}
catch (Exception ex)
{
return Error<StorageModeResponse>($"Failed to create storage mode: {ex.Message}", 500);
}
}
[HttpPost("update_mode")]
public async Task<ActionResult<BaseResult<StorageModeResponse>>> UpdateStorageMode(
[FromBody] UpdateStorageModeRequest request)
{
try
{
var result = await storageManagementService.UpdateStorageModeAsync(request);
return Success(result, "Storage mode updated successfully.");
}
catch (KeyNotFoundException ex)
{
return Error<StorageModeResponse>(ex.Message, 404);
}
catch (ArgumentException ex)
{
return Error<StorageModeResponse>(ex.Message, 400);
}
catch (Exception ex)
{
return Error<StorageModeResponse>($"Failed to update storage mode: {ex.Message}", 500);
}
}
[HttpPost("delete_mode")]
public async Task<ActionResult<BaseResult<bool>>> DeleteStorageMode([FromBody] int id)
{
try
{
var result = await storageManagementService.DeleteStorageModeAsync(id);
return Success(result, "Storage mode deleted successfully.");
}
catch (KeyNotFoundException ex)
{
return Error<bool>(ex.Message, 404);
}
catch (InvalidOperationException ex) // Catch specific exception for "in use"
{
return Error<bool>(ex.Message, 400);
}
catch (Exception ex)
{
return Error<bool>($"Failed to delete storage mode: {ex.Message}", 500);
}
}
[HttpPost("batch_delete_modes")]
public async Task<ActionResult<BaseResult<BatchDeleteResult>>> BatchDeleteStorageModes([FromBody] List<int> ids)
{
try
{
if (ids == null || !ids.Any())
{
return Error<BatchDeleteResult>("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<BatchDeleteResult>($"Batch delete failed: {ex.Message}", 500);
}
}
[HttpGet("get_storage_types")]
public async Task<ActionResult<BaseResult<IEnumerable<StorageTypeResponse>>>> GetStorageTypes()
{
try
{
var result = await storageManagementService.GetStorageTypesAsync();
return Success(result, "Storage types retrieved successfully.");
}
catch (Exception ex)
{
return Error<IEnumerable<StorageTypeResponse>>($"Failed to get storage types: {ex.Message}", 500);
}
}
[HttpGet("get_default_mode_id")]
public async Task<ActionResult<BaseResult<int?>>> GetDefaultStorageModeId()
{
try
{
var result = await storageManagementService.GetDefaultStorageModeIdAsync();
if (result.HasValue)
{
return Success<int?>(result.Value, "Default storage mode ID retrieved successfully.");
}
return Success<int?>(null, "No default storage mode is currently set or the configured one is invalid.");
}
catch (Exception ex)
{
return Error<int?>($"Failed to get default storage mode ID: {ex.Message}", 500);
}
}
[HttpPost("set_default_mode/{id}")]
public async Task<ActionResult<BaseResult<bool>>> 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<bool>(ex.Message, 404);
}
catch (InvalidOperationException ex)
{
return Error<bool>(ex.Message, 400);
}
catch (Exception ex)
{
return Error<bool>($"Failed to set default storage mode: {ex.Message}", 500);
}
}
}

View File

@@ -1,22 +1,23 @@
using Microsoft.AspNetCore.Authorization; using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc; using Foxel.Controllers;
using Foxel.Models; using Foxel.Models;
using Foxel.Models.DataBase; using Foxel.Models.DataBase;
using Foxel.Models.Request.Picture; using Foxel.Models.Request.Picture;
using Foxel.Models.Response.Picture; using Foxel.Models.Response.Picture;
using System.Text.Json;
using System.Text.Json.Serialization;
using Foxel.Services.Media; using Foxel.Services.Media;
using Foxel.Services.Storage; using Foxel.Services.Storage;
using System.IO; using Microsoft.AspNetCore.Authorization;
using Foxel.Services.Attributes; using Microsoft.AspNetCore.Mvc;
using System.Text.Json; // Added for JsonSerializer in GetTelegramFile if it were kept
namespace Foxel.Controllers; namespace Foxel.Api;
[Authorize] [Authorize]
[Route("api/picture")] [Route("api/picture")]
public class PictureController(IPictureService pictureService, IStorageService storageService) : BaseApiController public class PictureController(IPictureService pictureService, IStorageService storageService, ILogger<PictureController> logger) : BaseApiController
{ {
private readonly ILogger<PictureController> _logger = logger;
[HttpGet("get_pictures")] [HttpGet("get_pictures")]
public async Task<ActionResult<PaginatedResult<PictureResponse>>> GetPictures( public async Task<ActionResult<PaginatedResult<PictureResponse>>> GetPictures(
[FromQuery] FilteredPicturesRequest request) [FromQuery] FilteredPicturesRequest request)
@@ -81,7 +82,7 @@ public class PictureController(IPictureService pictureService, IStorageService s
userId, userId,
(PermissionType)request.Permission!, (PermissionType)request.Permission!,
request.AlbumId, request.AlbumId,
request.StorageType request.StorageModeId
); );
var picture = result.Picture; var picture = result.Picture;
@@ -260,92 +261,76 @@ public class PictureController(IPictureService pictureService, IStorageService s
} }
} }
[HttpGet("get_telegram_file")] [HttpGet("file/{pictureId}")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> GetTelegramFile([FromQuery] string fileId) public async Task<IActionResult> GetPictureFile(int pictureId)
{ {
try try
{ {
// 创建一个模拟的存储元数据 var picture = await pictureService.GetPictureByIdAsync(pictureId);
var metadata = new if (picture == null)
{ {
FileId = fileId, _logger.LogWarning("GetPictureFile: Picture with ID {PictureId} not found.", pictureId);
OriginalFileName = "telegram_file" return NotFound("Picture not found.");
};
// 序列化为 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));
} }
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) catch (Exception ex)
{ {
return StatusCode(500, $"代理获取文件失败: {ex.Message}"); _logger.LogError(ex, "GetPictureFile: Error getting file for picture ID {PictureId}", pictureId);
} return StatusCode(500, "An error occurred while retrieving the file.");
}
// 用于解析 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<IActionResult> 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}");
} }
} }
private string GetContentTypeFromPath(string path) private string GetContentTypeFromPath(string path)
{ {
string extension = Path.GetExtension(path).ToLowerInvariant(); string extension = Path.GetExtension(path).ToLowerInvariant();
return extension switch return extension switch
{ {
".jpg" or ".jpeg" => "image/jpeg", ".jpg" or ".jpeg" => "image/jpeg",

View File

@@ -31,13 +31,9 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IUserManagementService, UserManagementService>(); services.AddSingleton<IUserManagementService, UserManagementService>();
services.AddSingleton<IPictureManagementService, PictureManagementService>(); services.AddSingleton<IPictureManagementService, PictureManagementService>();
services.AddSingleton<ILogManagementService, LogManagementService>(); services.AddSingleton<ILogManagementService, LogManagementService>();
services.AddSingleton<IStorageManagementService, StorageManagementService>();
services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>(); services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
services.AddHostedService<QueuedHostedService>(); services.AddHostedService<QueuedHostedService>();
services.AddSingleton<LocalStorageProvider>();
services.AddSingleton<TelegramStorageProvider>();
services.AddSingleton<S3StorageProvider>();
services.AddSingleton<CosStorageProvider>();
services.AddSingleton<WebDavStorageProvider>();
services.AddSingleton<IStorageService, StorageService>(); services.AddSingleton<IStorageService, StorageService>();
services.AddSingleton<PictureTaskProcessor>(); services.AddSingleton<PictureTaskProcessor>();
services.AddSingleton<VisualRecognitionTaskProcessor>(); services.AddSingleton<VisualRecognitionTaskProcessor>();

View File

@@ -35,7 +35,9 @@ public class Picture : BaseModel
set => ExifInfoJson = value != null ? JsonSerializer.Serialize(value) : null; 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<Tag>? Tags { get; set; } public ICollection<Tag>? Tags { get; set; }
public int? UserId { get; set; } public int? UserId { get; set; }

View File

@@ -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; }
}

View File

@@ -1,7 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Foxel.Models.DataBase;
using Foxel.Models.Enums;
using Foxel.Services.Attributes;
namespace Foxel.Models.Request.Picture; namespace Foxel.Models.Request.Picture;
@@ -15,5 +13,5 @@ public record UploadPictureRequest
public int? AlbumId { get; set; } public int? AlbumId { get; set; }
public StorageType? StorageType { get; set; } public int? StorageModeId { get; set; }
} }

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -21,4 +21,6 @@ public record PictureResponse
public int? AlbumId { get; set; } public int? AlbumId { get; set; }
public string? AlbumName { get; set; } public string? AlbumName { get; set; }
public PermissionType Permission { get; set; } = PermissionType.Public; public PermissionType Permission { get; set; } = PermissionType.Public;
public string? StorageModeName { get; set; }
} }

View File

@@ -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; }
}

View File

@@ -0,0 +1,7 @@
namespace Foxel.Models.Response.Storage;
public class StorageTypeResponse
{
public int Value { get; set; }
public string Name { get; set; } = string.Empty;
}

View File

@@ -14,4 +14,5 @@ public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(opti
public DbSet<Role> Roles { get; set; } = null!; public DbSet<Role> Roles { get; set; } = null!;
public DbSet<Log> Logs { get; set; } = null!; public DbSet<Log> Logs { get; set; } = null!;
public DbSet<BackgroundTask> BackgroundTasks { get; set; } = null!; public DbSet<BackgroundTask> BackgroundTasks { get; set; } = null!;
public DbSet<StorageMode> StorageModes { get; set; } = null!;
} }

View File

@@ -1,49 +1,30 @@
using Foxel.Models.DataBase; using Foxel.Models.DataBase;
using Foxel.Services.AI;
using Foxel.Services.Storage; using Foxel.Services.Storage;
using Foxel.Services.VectorDB;
using Foxel.Utils; using Foxel.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text.Json; using System.Text.Json;
using Foxel.Services.Attributes;
using Foxel.Services.Background; // Added for IBackgroundTaskQueue
namespace Foxel.Services.Background.Processors namespace Foxel.Services.Background.Processors
{ {
public class PictureProcessingPayload // Ensure this is defined or imported public class PictureProcessingPayload
{ {
public int PictureId { get; set; } public int PictureId { get; set; }
public string OriginalFilePath { get; set; } = string.Empty; public string OriginalFilePath { get; set; } = string.Empty;
public int? UserIdForPicture { get; set; } public int? UserIdForPicture { get; set; }
} }
public class PictureTaskProcessor(
IDbContextFactory<MyDbContext> contextFactory,
public class PictureTaskProcessor : ITaskProcessor IServiceProvider serviceProvider,
ILogger<PictureTaskProcessor> logger)
: ITaskProcessor
{ {
private readonly IDbContextFactory<MyDbContext> _contextFactory;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<PictureTaskProcessor> _logger;
private readonly IWebHostEnvironment _environment;
public PictureTaskProcessor(
IDbContextFactory<MyDbContext> contextFactory,
IServiceProvider serviceProvider,
ILogger<PictureTaskProcessor> logger,
IWebHostEnvironment environment)
{
_contextFactory = contextFactory;
_serviceProvider = serviceProvider;
_logger = logger;
_environment = environment;
}
public async Task ProcessAsync(BackgroundTask backgroundTask) public async Task ProcessAsync(BackgroundTask backgroundTask)
{ {
if (backgroundTask.Payload == null) if (backgroundTask.Payload == null)
{ {
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "任务 Payload 为空。"); await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "任务 Payload 为空。");
_logger.LogError("任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id); logger.LogError("任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id);
return; return;
} }
@@ -54,125 +35,139 @@ namespace Foxel.Services.Background.Processors
} }
catch (JsonException ex) 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 解析失败。"); await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 解析失败。");
return; return;
} }
if (payload == null || payload.PictureId == 0) 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。"); await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0,
"Payload 无效或缺少 PictureId。");
return; return;
} }
var pictureId = payload.PictureId; var pictureId = payload.PictureId;
var originalFilePathFromPayload = payload.OriginalFilePath; var storageKeyForOriginalFile = payload.OriginalFilePath;
string localFilePath = ""; string localFilePath = "";
bool isTempFile = false; 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); var currentBackgroundTaskState = await dbContext.BackgroundTasks.FindAsync(backgroundTask.Id);
if (currentBackgroundTaskState == null) if (currentBackgroundTaskState == null)
{ {
_logger.LogError("在 PictureTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id); logger.LogError("在 PictureTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id);
return; 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 try
{ {
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 10, currentBackgroundTaskState: currentBackgroundTaskState); await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 10,
currentBackgroundTaskState: currentBackgroundTaskState);
if (picture == null) if (picture == null)
{ {
throw new Exception($"找不到ID为{pictureId}的图片。"); 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<IStorageService>(); var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>();
string contentRootPath = _environment.ContentRootPath; if (picture.StorageMode.StorageType == Attributes.StorageType.Local)
if (picture.StorageType == StorageType.Local)
{ {
localFilePath = Path.Combine(contentRootPath, originalFilePathFromPayload.TrimStart('/')); logger.LogInformation(
} "Picture {PictureId} is Local. Attempting to download via StorageService for consistency.",
else pictureId);
{
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 25, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress
localFilePath = await storageService.ExecuteAsync(picture.StorageType,
provider => provider.DownloadFileAsync(originalFilePathFromPayload));
isTempFile = true;
} }
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)) 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)) if (string.IsNullOrEmpty(picture.ThumbnailPath))
{ {
var tempThumbContainer = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); var tempThumbContainer = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(tempThumbContainer); Directory.CreateDirectory(tempThumbContainer);
string baseNameFromOriginalStorageKey = Path.GetFileNameWithoutExtension(picture.OriginalPath);
// Derive baseName from OriginalPath (which is in originalFilePathFromPayload) var thumbnailDiskPath = Path.Combine(tempThumbContainer,
// originalFilePathFromPayload is the stored path/key, not a local path. $"{baseNameFromOriginalStorageKey}-thumbnail-temp.webp");
// 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");
await ImageHelper.CreateThumbnailAsync(localFilePath, thumbnailDiskPath, 500); 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);
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 65, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress await using var thumbnailFileStream =
new FileStream(thumbnailDiskPath, FileMode.Open, FileAccess.Read);
await using var thumbnailFileStream = new FileStream(thumbnailDiskPath, FileMode.Open, FileAccess.Read); var thumbnailStorageFileName = $"{baseNameFromOriginalStorageKey}-thumbnail.webp";
// Use the new naming convention for storage
var thumbnailStorageFileName = $"{baseNameFromOriginalPath}-thumbnail.webp";
string storedThumbnailPath = await storageService.ExecuteAsync( string storedThumbnailPath = await storageService.ExecuteAsync(
picture.StorageType, picture.StorageModeId,
provider => provider.SaveAsync(thumbnailFileStream, thumbnailStorageFileName, "image/webp")); provider => provider.SaveAsync(thumbnailFileStream, thumbnailStorageFileName, "image/webp"));
picture.ThumbnailPath = storedThumbnailPath; picture.ThumbnailPath = storedThumbnailPath;
if (Directory.Exists(tempThumbContainer)) Directory.Delete(tempThumbContainer, true); 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); var exifInfo = await ImageHelper.ExtractExifInfoAsync(localFilePath);
picture.ExifInfo = exifInfo; picture.ExifInfo = exifInfo;
picture.TakenAt = ImageHelper.ParseExifDateTime(exifInfo.DateTimeOriginal); picture.TakenAt = ImageHelper.ParseExifDateTime(exifInfo.DateTimeOriginal);
await dbContext.SaveChangesAsync(); 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) catch (Exception ex)
{ {
_logger.LogError(ex, "图片元数据处理任务失败: TaskId={TaskId}, PictureId={PictureId}", currentBackgroundTaskState.Id, pictureId); logger.LogError(ex, "图片元数据处理任务失败: TaskId={TaskId}, PictureId={PictureId}",
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed, currentBackgroundTaskState.Progress, ex.Message, currentBackgroundTaskState: currentBackgroundTaskState); currentBackgroundTaskState.Id, pictureId);
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed,
currentBackgroundTaskState.Progress, ex.Message,
currentBackgroundTaskState: currentBackgroundTaskState);
} }
finally finally
{ {
if (isTempFile && File.Exists(localFilePath)) 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); var taskToUpdate = currentBackgroundTaskState ?? await dbContext.BackgroundTasks.FindAsync(taskId);
if (taskToUpdate != null) 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); dbContext.BackgroundTasks.Attach(currentBackgroundTaskState);
} }
@@ -183,23 +178,25 @@ namespace Foxel.Services.Background.Processors
if (startedAt.HasValue) taskToUpdate.StartedAt = startedAt; if (startedAt.HasValue) taskToUpdate.StartedAt = startedAt;
if (completedAt.HasValue) taskToUpdate.CompletedAt = completedAt; 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; taskToUpdate.StartedAt = taskToUpdate.CreatedAt;
} }
if (status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) if (status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed)
{ {
taskToUpdate.CompletedAt ??= DateTime.UtcNow; taskToUpdate.CompletedAt ??= DateTime.UtcNow;
} }
await dbContext.SaveChangesAsync(); 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 else
{ {
_logger.LogWarning("尝试在 Processor 中更新不存在的任务状态: TaskId={TaskId}", taskId); logger.LogWarning("尝试在 Processor 中更新不存在的任务状态: TaskId={TaskId}", taskId);
} }
} }
} }
} }

View File

@@ -5,7 +5,10 @@ using Foxel.Services.VectorDB;
using Foxel.Utils; using Foxel.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text.Json; 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 namespace Foxel.Services.Background.Processors
{ {
@@ -15,19 +18,32 @@ namespace Foxel.Services.Background.Processors
public int? UserIdForPicture { get; set; } public int? UserIdForPicture { get; set; }
} }
public class VisualRecognitionTaskProcessor( public class VisualRecognitionTaskProcessor : ITaskProcessor
IDbContextFactory<MyDbContext> contextFactory,
IServiceProvider serviceProvider,
ILogger<VisualRecognitionTaskProcessor> logger,
IWebHostEnvironment environment)
: ITaskProcessor
{ {
private readonly IDbContextFactory<MyDbContext> _contextFactory;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<VisualRecognitionTaskProcessor> _logger;
private readonly IWebHostEnvironment _environment;
public VisualRecognitionTaskProcessor(
IDbContextFactory<MyDbContext> contextFactory,
IServiceProvider serviceProvider,
ILogger<VisualRecognitionTaskProcessor> logger,
IWebHostEnvironment environment)
{
_contextFactory = contextFactory;
_serviceProvider = serviceProvider;
_logger = logger;
_environment = environment;
}
public async Task ProcessAsync(BackgroundTask backgroundTask) public async Task ProcessAsync(BackgroundTask backgroundTask)
{ {
// ... (payload deserialization and validation logic remains the same) ...
if (backgroundTask.Payload == null) if (backgroundTask.Payload == null)
{ {
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "任务 Payload 为空。"); await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "任务 Payload 为空。");
logger.LogError("视觉识别任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id); _logger.LogError("视觉识别任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id);
return; return;
} }
@@ -38,33 +54,37 @@ namespace Foxel.Services.Background.Processors
} }
catch (JsonException ex) 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 解析失败。"); await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 解析失败。");
return; return;
} }
if (payload == null || payload.PictureId == 0) 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, await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0,
"Payload 无效或缺少 PictureId。"); "Payload 无效或缺少 PictureId。");
return; return;
} }
var pictureId = payload.PictureId; var pictureId = payload.PictureId;
string thumbnailForAiDownloadPath = string.Empty; // Path if thumbnail needs to be downloaded string thumbnailForAiDownloadPath = string.Empty;
bool isTempThumbnailFile = false; 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); var currentBackgroundTaskState = await dbContext.BackgroundTasks.FindAsync(backgroundTask.Id);
if (currentBackgroundTaskState == null) if (currentBackgroundTaskState == null)
{ {
logger.LogError("在 VisualRecognitionTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id); _logger.LogError("在 VisualRecognitionTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id);
return; return;
} }
var picture = await dbContext.Pictures.Include(p => p.User).ThenInclude(u => u.Tags) var picture = await dbContext.Pictures
.FirstOrDefaultAsync(p => p.Id == pictureId); .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 try
{ {
@@ -75,35 +95,48 @@ namespace Foxel.Services.Background.Processors
{ {
throw new Exception($"找不到ID为{pictureId}的图片。"); throw new Exception($"找不到ID为{pictureId}的图片。");
} }
if (picture.StorageMode == null || picture.StorageModeId < 0)
{
throw new Exception($"图片ID {pictureId} 缺少有效的 StorageMode 配置。");
}
if (string.IsNullOrEmpty(picture.ThumbnailPath)) 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分析。"); throw new Exception($"图片ID {pictureId} 的缩略图路径为空无法进行AI分析。");
} }
using var scope = serviceProvider.CreateScope(); using var scope = _serviceProvider.CreateScope();
var aiService = scope.ServiceProvider.GetRequiredService<IAiService>(); var aiService = scope.ServiceProvider.GetRequiredService<IAiService>();
var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>(); var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>();
string contentRootPath = environment.ContentRootPath; string contentRootPath = _environment.ContentRootPath;
string actualThumbnailPathForAI; 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('/')); // As with PictureTaskProcessor, safer to use DownloadFileAsync for consistency
} _logger.LogInformation("Picture {PictureId} thumbnail is Local. Attempting to download via StorageService for AI.", pictureId);
else // Remote storage // Fall-through
{
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 15,
currentBackgroundTaskState: currentBackgroundTaskState);
thumbnailForAiDownloadPath = await storageService.ExecuteAsync(picture.StorageType,
provider => provider.DownloadFileAsync(picture.ThumbnailPath));
actualThumbnailPathForAI = thumbnailForAiDownloadPath;
isTempThumbnailFile = true;
} }
// 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)) 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, await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 20,
@@ -116,11 +149,11 @@ namespace Foxel.Services.Background.Processors
string finalTitle = !string.IsNullOrWhiteSpace(title) && title != "AI生成的标题" string finalTitle = !string.IsNullOrWhiteSpace(title) && title != "AI生成的标题"
? title ? title
: Path.GetFileNameWithoutExtension(picture.Name); : Path.GetFileNameWithoutExtension(picture.Name); // Fallback to existing name
string finalDescription = !string.IsNullOrWhiteSpace(description) && description != "AI生成的描述" string finalDescription = !string.IsNullOrWhiteSpace(description) && description != "AI生成的描述"
? description ? description
: picture.Description; : picture.Description; // Fallback to existing description
picture.Name = finalTitle; // Potentially overwrites name set from filename picture.Name = finalTitle;
picture.Description = finalDescription; picture.Description = finalDescription;
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 60, await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 60,
@@ -135,7 +168,7 @@ namespace Foxel.Services.Background.Processors
await vectorDbService.AddPictureToUserCollectionAsync(picture.UserId.Value, await vectorDbService.AddPictureToUserCollectionAsync(picture.UserId.Value,
new Models.Vector.PictureVector 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, Name = picture.Name,
Embedding = embedding Embedding = embedding
}); });
@@ -151,7 +184,7 @@ namespace Foxel.Services.Background.Processors
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 90, await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 90,
currentBackgroundTaskState: currentBackgroundTaskState); 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<Tag>(); picture.Tags ??= new List<Tag>();
foreach (var tagName in matchedTagNames) foreach (var tagName in matchedTagNames)
@@ -162,12 +195,17 @@ namespace Foxel.Services.Background.Processors
{ {
existingTag = new Tag { Name = tagName.Trim(), Description = tagName.Trim() }; existingTag = new Tag { Name = tagName.Trim(), Description = tagName.Trim() };
dbContext.Tags.Add(existingTag); 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); if (picture.Tags.All(t => t.Id != existingTag.Id)) picture.Tags.Add(existingTag);
picture.User.Tags ??= new List<Tag>(); // Add to user's tags if user exists
if (picture.User.Tags.All(t => t.Id != existingTag.Id)) picture.User.Tags.Add(existingTag); if (picture.User != null)
{
picture.User.Tags ??= new List<Tag>();
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) catch (Exception ex)
{ {
logger.LogError(ex, "视觉识别任务失败: TaskId={TaskId}, PictureId={PictureId}", currentBackgroundTaskState.Id, _logger.LogError(ex, "视觉识别任务失败: TaskId={TaskId}, PictureId={PictureId}", currentBackgroundTaskState.Id,
pictureId); pictureId);
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed, await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed,
currentBackgroundTaskState.Progress, ex.Message, currentBackgroundTaskState.Progress, ex.Message,
@@ -193,17 +231,18 @@ namespace Foxel.Services.Background.Processors
} }
catch (Exception ex) 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, private async Task UpdateTaskStatusInDb(Guid taskId, TaskExecutionStatus status, int progress,
string? error = null, DateTime? startedAt = null, DateTime? completedAt = null, string? error = null, DateTime? startedAt = null, DateTime? completedAt = null,
BackgroundTask? currentBackgroundTaskState = 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); var taskToUpdate = currentBackgroundTaskState ?? await dbContext.BackgroundTasks.FindAsync(taskId);
if (taskToUpdate != null) if (taskToUpdate != null)
@@ -219,30 +258,30 @@ namespace Foxel.Services.Background.Processors
taskToUpdate.ErrorMessage = taskToUpdate.ErrorMessage =
string.IsNullOrEmpty(error) string.IsNullOrEmpty(error)
? taskToUpdate.ErrorMessage ? taskToUpdate.ErrorMessage
: error; // Keep existing error if new one is null/empty : error;
if (startedAt.HasValue) taskToUpdate.StartedAt = startedAt; if (startedAt.HasValue) taskToUpdate.StartedAt = startedAt;
if (completedAt.HasValue) taskToUpdate.CompletedAt = completedAt; if (completedAt.HasValue) taskToUpdate.CompletedAt = completedAt;
if ((status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) && if ((status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) &&
!taskToUpdate.StartedAt.HasValue) !taskToUpdate.StartedAt.HasValue)
{ {
taskToUpdate.StartedAt = taskToUpdate.CreatedAt; // Ensure StartedAt is set taskToUpdate.StartedAt = taskToUpdate.CreatedAt;
} }
if (status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) if (status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed)
{ {
taskToUpdate.CompletedAt ??= DateTime.UtcNow; // Ensure CompletedAt is set taskToUpdate.CompletedAt ??= DateTime.UtcNow;
} }
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
logger.LogInformation( _logger.LogInformation(
"任务状态更新 (VisualRecognitionProcessor): TaskId={TaskId}, Status={Status}, Progress={Progress}%", "任务状态更新 (VisualRecognitionProcessor): TaskId={TaskId}, Status={Status}, Progress={Progress}%",
taskId, status, progress); taskId, status, progress);
} }
else else
{ {
logger.LogWarning("尝试在 VisualRecognitionProcessor 中更新不存在的任务状态: TaskId={TaskId}", taskId); _logger.LogWarning("尝试在 VisualRecognitionProcessor 中更新不存在的任务状态: TaskId={TaskId}", taskId);
} }
} }
} }

View File

@@ -16,12 +16,6 @@ public class ConfigService(
{ {
"AI:ApiKey", "AI:ApiKey",
"Authentication:GitHubClientSecret", "Authentication:GitHubClientSecret",
"Storage:TelegramStorageBotToken",
"Storage:S3StorageAccessKey",
"Storage:S3StorageSecretKey",
"Storage:CosStorageSecretId",
"Storage:CosStorageSecretKey",
"Storage:WebDAVPassword",
}; };
// 缓存过期时间 // 缓存过期时间

View File

@@ -1,4 +1,5 @@
using Foxel.Models.DataBase; using Foxel.Models.DataBase;
using Foxel.Services.Attributes;
using Foxel.Services.Configuration; using Foxel.Services.Configuration;
using Foxel.Services.Logging; using Foxel.Services.Logging;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -17,7 +18,7 @@ public class DatabaseInitializer(
{ {
// 在初始化期间禁用数据库日志记录 // 在初始化期间禁用数据库日志记录
DatabaseLogger.SetDatabaseReady(false); DatabaseLogger.SetDatabaseReady(false);
logger.LogInformation("开始检查数据库初始化状态..."); logger.LogInformation("开始检查数据库初始化状态...");
// 执行数据库迁移 // 执行数据库迁移
@@ -28,7 +29,6 @@ public class DatabaseInitializer(
configService[InitializationFlag] == "true") configService[InitializationFlag] == "true")
{ {
logger.LogInformation("数据库已完成初始化,跳过初始化步骤"); logger.LogInformation("数据库已完成初始化,跳过初始化步骤");
// 启用数据库日志记录
DatabaseLogger.SetDatabaseReady(true); DatabaseLogger.SetDatabaseReady(true);
return; 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**.", "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"] = ["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.", "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"] = "", ["Upload:HighQualityImageCompressionQuality"] = "85",
["Storage:DefaultStorage"] = "Local", ["Upload:ThumbnailMaxWidth"] = "500",
["Upload:ThumbnailCompressionQuality"] = "75",
// 其他配置 // 其他配置
["Storage:DefaultStorageModeId"] = "1",
["AppSettings:ServerUrl"] = "", ["AppSettings:ServerUrl"] = "",
["VectorDb:Type"] = "InMemory" ["VectorDb:Type"] = "InMemory"
}; };
@@ -88,11 +90,14 @@ public class DatabaseInitializer(
// 初始化管理员角色和用户 // 初始化管理员角色和用户
await InitializeAdminRoleAndUserAsync(); await InitializeAdminRoleAndUserAsync();
// 初始化默认存储模式
await InitializeDefaultStorageModeAsync();
// 标记初始化已完成 // 标记初始化已完成
await configService.SetConfigAsync(InitializationFlag, "true", "系统初始化完成标志"); await configService.SetConfigAsync(InitializationFlag, "true", "系统初始化完成标志");
logger.LogInformation("数据库配置初始化完成"); logger.LogInformation("数据库配置初始化完成");
// 初始化完成后启用数据库日志记录 // 初始化完成后启用数据库日志记录
DatabaseLogger.SetDatabaseReady(true); DatabaseLogger.SetDatabaseReady(true);
} }
@@ -162,4 +167,32 @@ public class DatabaseInitializer(
logger.LogInformation("请注意,第一个注册的用户将自动成为管理员"); 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);
}
}
} }

View File

@@ -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<PaginatedResult<StorageModeResponse>> GetStorageModesAsync(int page = 1, int pageSize = 10, string? searchQuery = null, StorageType? storageType = null, bool? isEnabled = null);
Task<StorageModeResponse> GetStorageModeByIdAsync(int id);
Task<StorageModeResponse> CreateStorageModeAsync(CreateStorageModeRequest request);
Task<StorageModeResponse> UpdateStorageModeAsync(UpdateStorageModeRequest request);
Task<bool> DeleteStorageModeAsync(int id);
Task<BatchDeleteResult> BatchDeleteStorageModesAsync(List<int> ids);
Task<IEnumerable<StorageTypeResponse>> GetStorageTypesAsync();
Task<int?> GetDefaultStorageModeIdAsync();
Task<bool> SetDefaultStorageModeAsync(int storageModeId);
}

View File

@@ -48,10 +48,10 @@ public class PictureManagementService(
{ {
Id = picture.Id, Id = picture.Id,
Name = picture.Name, Name = picture.Name,
Path = storageService.ExecuteAsync(picture.StorageType, provider => Path = storageService.ExecuteAsync(picture.StorageModeId, provider =>
Task.FromResult(provider.GetUrl(picture.Path))).Result, Task.FromResult(provider.GetUrl(picture.Id,picture.Path))).Result,
ThumbnailPath = storageService.ExecuteAsync(picture.StorageType, provider => ThumbnailPath = storageService.ExecuteAsync(picture.StorageModeId, provider =>
Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? string.Empty))).Result, Task.FromResult(provider.GetUrl(picture.Id,picture.ThumbnailPath ?? string.Empty))).Result,
Description = picture.Description, Description = picture.Description,
CreatedAt = picture.CreatedAt, CreatedAt = picture.CreatedAt,
TakenAt = picture.TakenAt, TakenAt = picture.TakenAt,
@@ -119,7 +119,7 @@ public class PictureManagementService(
// 保存文件路径信息用于后续删除 // 保存文件路径信息用于后续删除
var filePath = picture.Path; var filePath = picture.Path;
var thumbnailPath = picture.ThumbnailPath; var thumbnailPath = picture.ThumbnailPath;
var storageType = picture.StorageType; var storageType = picture.StorageModeId;
// 删除数据库记录 // 删除数据库记录
dbContext.Pictures.Remove(picture); dbContext.Pictures.Remove(picture);

View File

@@ -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<MyDbContext> _contextFactory;
private readonly ILogger<StorageManagementService> _logger;
private readonly IConfigService _configService; // Added IConfigService
private const string DefaultStorageModeIdKey = "Storage:DefaultStorageModeId"; // Define key for config
public StorageManagementService(
IDbContextFactory<MyDbContext> contextFactory,
ILogger<StorageManagementService> logger,
IConfigService configService) // Added IConfigService to constructor
{
_contextFactory = contextFactory;
_logger = logger;
_configService = configService; // Initialize IConfigService
}
public async Task<PaginatedResult<StorageModeResponse>> 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<StorageModeResponse>
{
Data = responseItems,
Page = page,
PageSize = pageSize,
TotalCount = totalCount
};
}
public async Task<StorageModeResponse> 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<StorageModeResponse> 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<StorageModeResponse> 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<bool> 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<BatchDeleteResult> BatchDeleteStorageModesAsync(List<int> 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<IEnumerable<StorageTypeResponse>> GetStorageTypesAsync()
{
var types = Enum.GetValues(typeof(StorageType))
.Cast<StorageType>()
.Select(e => new StorageTypeResponse { Value = (int)e, Name = e.ToString() })
.ToList();
return Task.FromResult<IEnumerable<StorageTypeResponse>>(types);
}
public async Task<int?> 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<bool> 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<LocalStorageConfig>(jsonConfig),
StorageType.Telegram => JsonSerializer.Deserialize<TelegramStorageConfig>(jsonConfig),
StorageType.S3 => JsonSerializer.Deserialize<S3StorageConfig>(jsonConfig),
StorageType.Cos => JsonSerializer.Deserialize<CosStorageConfig>(jsonConfig),
StorageType.WebDAV => JsonSerializer.Deserialize<WebDavStorageConfig>(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);
}
}
}

View File

@@ -1,8 +1,6 @@
using Foxel.Models; using Foxel.Models;
using Foxel.Models.DataBase; using Foxel.Models.DataBase;
using Foxel.Models.Enums;
using Foxel.Models.Response.Picture; using Foxel.Models.Response.Picture;
using Foxel.Services.Attributes;
namespace Foxel.Services.Media; namespace Foxel.Services.Media;
@@ -34,7 +32,7 @@ public interface IPictureService
int? userId, int? userId,
PermissionType permission = PermissionType.Public, PermissionType permission = PermissionType.Public,
int? albumId = null, int? albumId = null,
StorageType? storageType = null int? storageModeId = null
); );
Task<ExifInfo> GetPictureExifInfoAsync(int pictureId); Task<ExifInfo> GetPictureExifInfoAsync(int pictureId);
@@ -85,5 +83,10 @@ public interface IPictureService
/// <returns>是否收藏</returns> /// <returns>是否收藏</returns>
Task<bool> IsPictureFavoritedByUserAsync(int pictureId, int userId); Task<bool> IsPictureFavoritedByUserAsync(int pictureId, int userId);
/// <summary>
/// 根据ID获取图片详情
/// </summary>
/// <param name="pictureId">图片ID</param>
/// <returns>图片响应对象如果未找到则返回null</returns>
Task<Picture?> GetPictureByIdAsync(int pictureId);
} }

View File

@@ -4,14 +4,12 @@ using Foxel.Models.DataBase;
using Foxel.Models.Enums; using Foxel.Models.Enums;
using Foxel.Models.Response.Picture; using Foxel.Models.Response.Picture;
using Foxel.Services.AI; using Foxel.Services.AI;
using Foxel.Services.Attributes;
using Foxel.Services.Background; using Foxel.Services.Background;
using Foxel.Services.Configuration; using Foxel.Services.Configuration;
using Foxel.Services.Storage; using Foxel.Services.Storage;
using Foxel.Services.VectorDB; using Foxel.Services.VectorDB;
using Foxel.Utils; using Foxel.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Foxel.Services.Media; namespace Foxel.Services.Media;
@@ -25,6 +23,48 @@ public class PictureService(
ILogger<PictureService> logger) ILogger<PictureService> logger)
: IPictureService : IPictureService
{ {
private async Task<PictureResponse> 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<string>(),
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<PaginatedResult<PictureResponse>> GetPicturesAsync( public async Task<PaginatedResult<PictureResponse>> GetPicturesAsync(
int page = 1, int page = 1,
int pageSize = 8, int pageSize = 8,
@@ -103,18 +143,21 @@ public class PictureService(
var picturesData = await dbContext.Pictures var picturesData = await dbContext.Pictures
.Include(p => p.Tags) .Include(p => p.Tags)
.Include(p => p.User) .Include(p => p.User)
.Include(p=>p.StorageMode)
.Where(p => ids.Contains((ulong)p.Id)) .Where(p => ids.Contains((ulong)p.Id))
.ToListAsync(); .ToListAsync();
var picturesOrdered = ids var picturesOrdered = ids
.Select(id => picturesData.FirstOrDefault(p => p.Id == (int)id)) .Select(id => picturesData.FirstOrDefault(p => p.Id == (int)id))
.Where(p => p != null) .Where(p => p != null)
.ToList(); .ToList();
var paginatedResults = picturesOrdered var paginatedResultsTasks = picturesOrdered
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.Select(p => MapPictureToResponse(p!)) .Select(async p => await MapPictureToResponseAsync(p!))
.ToList(); .ToList();
var paginatedResults = (await Task.WhenAll(paginatedResultsTasks)).ToList();
var totalCount = picturesOrdered.Count; var totalCount = picturesOrdered.Count;
await PopulateFavoriteInfo(dbContext, paginatedResults, userId); await PopulateFavoriteInfo(dbContext, paginatedResults, userId);
@@ -177,14 +220,16 @@ public class PictureService(
// 获取分页数据 // 获取分页数据
var picturesData = await query var picturesData = await query
.Include(x=>x.StorageMode)
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.ToListAsync(); .ToListAsync();
// 转换为响应格式 // 转换为响应格式
var pictures = picturesData var picturesTasks = picturesData
.Select(p => MapPictureToResponse(p)) .Select(async p => await MapPictureToResponseAsync(p))
.ToList(); .ToList();
var pictures = (await Task.WhenAll(picturesTasks)).ToList();
// 处理收藏信息 // 处理收藏信息
await PopulateFavoriteInfo(dbContext, pictures, userId); 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<string>(),
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<PictureResponse> pictures, int? userId) private async Task PopulateFavoriteInfo(MyDbContext dbContext, List<PictureResponse> pictures, int? userId)
{ {
@@ -444,41 +461,60 @@ public class PictureService(
int? userId, int? userId,
PermissionType permission = PermissionType.Public, PermissionType permission = PermissionType.Public,
int? albumId = null, 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]; string configKey = userId == null
return !string.IsNullOrEmpty(configValue) && ? "Storage:DefaultStorageModeId"
Enum.TryParse<StorageType>(configValue, out var configStorageType) : "Storage:DefaultStorageModeId";
? configStorageType string? defaultMode = configuration[configKey];
: StorageType.Local;
}
if (userId == null) if (string.IsNullOrEmpty(defaultMode))
{
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<ImageFormat>(defaultFormatConfig, true, out var parsedFormat))
{ {
convertToFormat = parsedFormat; logger.LogError("未配置默认存储模式ID: {ConfigKey}", configKey);
throw new InvalidOperationException($"未配置默认存储模式: {configKey}");
}
var defaultStorageMode = await dbContext.Set<StorageMode>()
.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<StorageMode>().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; ImageFormat convertToFormat = ImageFormat.WebP;
string defaultQualityConfig = configuration["Upload:DefaultImageQuality"];
if (!string.IsNullOrEmpty(defaultQualityConfig)) // 高清图片压缩质量
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(); string baseName = Guid.NewGuid().ToString();
@@ -488,92 +524,104 @@ public class PictureService(
string? tempOriginalLocalPath = null; string? tempOriginalLocalPath = null;
string? tempConvertedHdLocalPath = null; string? tempConvertedHdLocalPath = null;
string? tempThumbnailLocalPath = null; string? tempThumbnailLocalPath = null;
string storedOriginalPath;
string storedHdPath;
string? storedThumbnailPath = null; string? storedThumbnailPath = null;
try try
{ {
tempOriginalLocalPath = Path.GetTempFileName() + originalFileExtension; tempOriginalLocalPath = Path.GetTempFileName() + originalFileExtension;
File.Move(Path.GetTempFileName(), tempOriginalLocalPath); File.Move(Path.GetTempFileName(), tempOriginalLocalPath);
await using (var tempFileStream = new FileStream(tempOriginalLocalPath, FileMode.Create)) await using (var tempFileStream = new FileStream(tempOriginalLocalPath, FileMode.Create))
{ {
await fileStream.CopyToAsync(tempFileStream); 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)); provider => provider.SaveAsync(originalLocalStream, originalStorageFileName, contentType));
} }
string hdStorageFileName;
string hdContentType;
string sourceForHdProcessing = tempOriginalLocalPath; string sourceForHdProcessing = tempOriginalLocalPath;
if (convertToFormat != ImageFormat.Original) string convertedExtension = ImageHelper.GetFileExtensionFromFormat(convertToFormat);
{ var hdStorageFileName = $"{baseName}-high-definition{convertedExtension}";
string convertedExtension = ImageHelper.GetFileExtensionFromFormat(convertToFormat); var hdContentType = ImageHelper.GetMimeTypeFromFormat(convertToFormat);
hdStorageFileName = $"{baseName}-high-definition{convertedExtension}";
hdContentType = ImageHelper.GetMimeTypeFromFormat(convertToFormat);
tempConvertedHdLocalPath = Path.GetTempFileName() + convertedExtension;
File.Move(Path.GetTempFileName(), tempConvertedHdLocalPath);
await ImageHelper.ConvertImageFormatAsync(sourceForHdProcessing, tempConvertedHdLocalPath, convertToFormat, quality); tempConvertedHdLocalPath = Path.GetTempFileName() + convertedExtension;
File.Move(Path.GetTempFileName(), tempConvertedHdLocalPath);
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;
}
// 4. Generate and upload thumbnail (Picture.ThumbnailPath) await ImageHelper.ConvertImageFormatAsync(sourceForHdProcessing, tempConvertedHdLocalPath, convertToFormat,
bool shouldGenerateThumbnailNow = userId.HasValue; 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) if (shouldGenerateThumbnailNow)
{ {
try 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"; tempThumbnailLocalPath = Path.GetTempFileName() + ".webp";
File.Move(Path.GetTempFileName(), tempThumbnailLocalPath); File.Move(Path.GetTempFileName(), tempThumbnailLocalPath);
await ImageHelper.CreateThumbnailAsync(tempOriginalLocalPath, tempThumbnailLocalPath, thumbnailMaxWidth, thumbnailQuality);
await ImageHelper.CreateThumbnailAsync(tempOriginalLocalPath, tempThumbnailLocalPath, 500);
string thumbnailUploadFileName = $"{baseName}-thumbnail.webp"; string thumbnailUploadFileName = $"{baseName}-thumbnail.webp";
await using var thumbnailFileStream = new FileStream(tempThumbnailLocalPath, FileMode.Open, FileAccess.Read); await using var thumbnailFileStream =
storedThumbnailPath = await storageService.ExecuteAsync(storageType.Value, new FileStream(tempThumbnailLocalPath!, FileMode.Open, FileAccess.Read);
provider => provider.SaveAsync(thumbnailFileStream, thumbnailUploadFileName, "image/webp")); storedThumbnailPath = await storageService.ExecuteAsync(storageModeId.Value,
provider => provider.SaveAsync(thumbnailFileStream, thumbnailUploadFileName!, "image/webp"));
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "生成和上传缩略图失败 during initial upload"); 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 initialTitle = Path.GetFileNameWithoutExtension(fileName);
string initialDescription = $"Uploaded on {DateTime.UtcNow}"; string initialDescription = $"Uploaded on {DateTime.UtcNow}";
await using var dbContext = await contextFactory.CreateDbContextAsync(); await using var dbContextAsync = await contextFactory.CreateDbContextAsync();
User? user = null; User? user = null;
if (userId is not null) if (userId is not null)
{ {
user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId); user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId);
if (user == null) throw new Exception("找不到指定的用户"); if (user == null) throw new Exception("找不到指定的用户");
} }
Album? album = null;
if (albumId.HasValue) 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); .FirstOrDefaultAsync(a => a.Id == albumId.Value);
if (album == null) if (album == null)
@@ -592,24 +640,21 @@ public class PictureService(
{ {
Name = initialTitle, Name = initialTitle,
Description = initialDescription, Description = initialDescription,
OriginalPath = storedOriginalPath, // Store path to original uploaded file OriginalPath = storedOriginalPath,
Path = storedHdPath, // Store path to HD (possibly converted) file Path = storedHdPath,
ThumbnailPath = storedThumbnailPath, // Store path to thumbnail ThumbnailPath = storedThumbnailPath,
User = user, User = user,
Permission = permission, Permission = permission,
AlbumId = albumId, AlbumId = albumId,
StorageType = storageType.Value, StorageModeId = storageModeId.Value,
// ProcessingStatus 等字段已移除
}; };
dbContext.Pictures.Add(picture); dbContext.Pictures.Add(picture);
await dbContext.SaveChangesAsync(); 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); await backgroundTaskQueue.QueuePictureProcessingTaskAsync(picture.Id, picture.OriginalPath);
var visualRecognitionPayload = new Background.Processors.VisualRecognitionPayload var visualRecognitionPayload = new Background.Processors.VisualRecognitionPayload
{ {
PictureId = picture.Id, PictureId = picture.Id,
@@ -618,43 +663,50 @@ public class PictureService(
await backgroundTaskQueue.QueueVisualRecognitionTaskAsync(visualRecognitionPayload); await backgroundTaskQueue.QueueVisualRecognitionTaskAsync(visualRecognitionPayload);
} }
var pictureResponse = new PictureResponse var pictureResponse = await MapPictureToResponseAsync(picture);
{
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<string>(),
Permission = permission,
AlbumId = albumId,
AlbumName = album?.Name,
// ProcessingStatus 字段已移除
};
return (pictureResponse, picture.Id); return (pictureResponse, picture.Id);
} }
finally finally
{ {
// Clean up temporary local files
if (!string.IsNullOrEmpty(tempOriginalLocalPath) && File.Exists(tempOriginalLocalPath)) 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)) 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)) 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(); await using var dbContext = await contextFactory.CreateDbContextAsync();
// 在查询时包含 StorageModeId
var picturesToDelete = await dbContext.Pictures var picturesToDelete = await dbContext.Pictures
.Include(p => p.User) .Include(p => p.User)
.Where(p => pictureIds.Contains(p.Id)) .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(); .ToListAsync();
var foundPictureIds = picturesToDelete.Select(p => p.Id).ToHashSet(); var foundPictureIds = picturesToDelete.Select(p => p.Id).ToHashSet();
@@ -704,60 +766,63 @@ public class PictureService(
results[id] = (false, "找不到此图片", null); 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()) if (picturesToDelete.Any())
{ {
dbContext.Pictures.RemoveRange(picturesToDelete); var idsToRemove = picturesToDelete.Select(p => p.Id).ToList();
await dbContext.SaveChangesAsync(); // 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 try
{ {
string? errorMsg = null; string? errorMsg = null;
if (picInfo.StorageModeId < 0)
{
results[picInfo.Id] = (false, "图片记录缺少有效的StorageModeId无法删除文件。", picInfo.UserId);
logger.LogWarning("图片 {PictureId} 缺少 StorageModeId跳过文件删除。", picInfo.Id);
continue;
}
try try
{ {
// Delete original file if (!string.IsNullOrEmpty(picInfo.OriginalPath))
if (!string.IsNullOrEmpty(originalPath))
{ {
await storageService.ExecuteAsync(storageType, await storageService.ExecuteAsync(picInfo.StorageModeId,
provider => provider.DeleteAsync(originalPath)); provider => provider.DeleteAsync(picInfo.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));
} }
// Delete thumbnail if (!string.IsNullOrEmpty(picInfo.Path) && picInfo.Path != picInfo.OriginalPath)
if (!string.IsNullOrEmpty(thumbnailPath))
{ {
await storageService.ExecuteAsync(storageType, await storageService.ExecuteAsync(picInfo.StorageModeId,
provider => provider.DeleteAsync(thumbnailPath)); provider => provider.DeleteAsync(picInfo.Path));
}
if (!string.IsNullOrEmpty(picInfo.ThumbnailPath))
{
await storageService.ExecuteAsync(picInfo.StorageModeId,
provider => provider.DeleteAsync(picInfo.ThumbnailPath));
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
errorMsg = $"数据库记录已删除,但删除文件失败: {ex.Message}"; 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) 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 var picture = await dbContext.Pictures
.Include(p => p.User) .Include(p => p.User)
.Include(p => p.Tags) .Include(p => p.Tags)
.Include(p => p.StorageMode)
.FirstOrDefaultAsync(p => p.Id == pictureId); .FirstOrDefaultAsync(p => p.Id == pictureId);
if (picture == null) if (picture == null)
@@ -840,25 +906,7 @@ public class PictureService(
picture.UpdatedAt = DateTime.UtcNow; picture.UpdatedAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
var pictureResponse = await MapPictureToResponseAsync(picture);
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<string>(),
TakenAt = picture.TakenAt,
ExifInfo = picture.ExifInfo
};
return (pictureResponse, userId); return (pictureResponse, userId);
} }
@@ -921,4 +969,25 @@ public class PictureService(
return await dbContext.Favorites return await dbContext.Favorites
.AnyAsync(f => f.PictureId == pictureId && f.User.Id == userId); .AnyAsync(f => f.PictureId == pictureId && f.User.Id == userId);
} }
public async Task<Picture?> 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;
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,10 +33,10 @@ public static class ImageHelper
/// </summary> /// </summary>
/// <param name="originalPath">原始图片路径</param> /// <param name="originalPath">原始图片路径</param>
/// <param name="thumbnailPath">缩略图保存路径</param> /// <param name="thumbnailPath">缩略图保存路径</param>
/// <param name="width">缩略图宽度</param> /// <param name="maxWidth">缩略图最大宽度</param>
/// <param name="quality">压缩质量(1-100)</param> /// <param name="quality">压缩质量(1-100)</param>
/// <returns>生成的缩略图的文件大小(字节)</returns> /// <returns>生成的缩略图的文件大小(字节)</returns>
public static async Task<long> CreateThumbnailAsync(string originalPath, string thumbnailPath, int width, public static async Task<long> CreateThumbnailAsync(string originalPath, string thumbnailPath, int maxWidth,
int quality = 75) int quality = 75)
{ {
// 获取原始文件大小 // 获取原始文件大小
@@ -45,16 +45,14 @@ public static class ImageHelper
using var image = await Image.LoadAsync(originalPath); using var image = await Image.LoadAsync(originalPath);
// 去除EXIF元数据以减小文件大小
image.Metadata.ExifProfile = null; image.Metadata.ExifProfile = null;
image.Mutate(x => x.Resize(new ResizeOptions image.Mutate(x => x.Resize(new ResizeOptions
{ {
Size = new Size(width, 0), Size = new Size(maxWidth, 0),
Mode = ResizeMode.Max Mode = ResizeMode.Max
})); }));
// 强制使用 WebP 格式,修改缩略图路径扩展名
string webpThumbnailPath = Path.ChangeExtension(thumbnailPath, ".webp"); string webpThumbnailPath = Path.ChangeExtension(thumbnailPath, ".webp");
int adjustedQuality = AdjustQualityByFileSize(originalSize, ".webp", quality); int adjustedQuality = AdjustQualityByFileSize(originalSize, ".webp", quality);
@@ -386,28 +384,28 @@ public static class ImageHelper
switch (targetFormat) switch (targetFormat)
{ {
case ImageFormat.Jpeg: case ImageFormat.Jpeg:
await convertedImage.SaveAsJpegAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder await convertedImage.SaveAsJpegAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder
{ {
Quality = quality Quality = quality
}); });
break; break;
case ImageFormat.Png: case ImageFormat.Png:
await convertedImage.SaveAsPngAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Png.PngEncoder await convertedImage.SaveAsPngAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Png.PngEncoder
{ {
CompressionLevel = SixLabors.ImageSharp.Formats.Png.PngCompressionLevel.BestCompression, CompressionLevel = SixLabors.ImageSharp.Formats.Png.PngCompressionLevel.BestCompression,
ColorType = SixLabors.ImageSharp.Formats.Png.PngColorType.RgbWithAlpha ColorType = SixLabors.ImageSharp.Formats.Png.PngColorType.RgbWithAlpha
}); });
break; break;
case ImageFormat.WebP: case ImageFormat.WebP:
await convertedImage.SaveAsWebpAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Webp.WebpEncoder await convertedImage.SaveAsWebpAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Webp.WebpEncoder
{ {
Quality = quality, Quality = quality,
Method = SixLabors.ImageSharp.Formats.Webp.WebpEncodingMethod.BestQuality Method = SixLabors.ImageSharp.Formats.Webp.WebpEncodingMethod.BestQuality
}); });
break; break;
} }
} }

View File

@@ -20,6 +20,13 @@ export interface PaginatedResult<T> {
code: number; 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 const BASE_URL = import.meta.env.PROD ? '/api' : 'http://localhost:5153/api';
export async function fetchApi<T = any>( export async function fetchApi<T = any>(

View File

@@ -8,4 +8,5 @@ export * from './pictureApi';
export * from './pictureManagementApi'; export * from './pictureManagementApi';
export * from './tagApi'; export * from './tagApi';
export * from './userManagementApi'; export * from './userManagementApi';
export * from './vectorDbApi'; export * from './vectorDbApi';
export * from './storageManagementApi';

View File

@@ -1,5 +1,4 @@
import { fetchApi, type BaseResult, type PaginatedResult } from './fetchClient'; import { fetchApi, type BaseResult, type BatchDeleteResult, type PaginatedResult } from './fetchClient';
import { type BatchDeleteResult } from './userManagementApi';
// 日志级别枚举 // 日志级别枚举

View File

@@ -40,6 +40,7 @@ export interface PictureResponse {
permission: number; permission: number;
albumId?: number; albumId?: number;
albumName?: string; albumName?: string;
storageModeName:string;
} }
// 收藏请求 // 收藏请求

View File

@@ -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 PictureResponse } from './pictureApi';
import { type BatchDeleteResult } from './userManagementApi';
// 获取图片列表 // 获取图片列表

View File

@@ -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, string> = {
[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<PaginatedResult<StorageModeResponse>> => {
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<StorageModeResponse>;
};
// 根据ID获取单个存储模式
export const getStorageModeById = async (id: number): Promise<BaseResult<StorageModeResponse>> => {
return fetchApi<StorageModeResponse>(
`/management/storage/get_mode/${id}`,
{ method: 'GET' }
);
};
// 创建存储模式
export const createStorageMode = async (
data: CreateStorageModeRequest
): Promise<BaseResult<StorageModeResponse>> => {
return fetchApi<StorageModeResponse>(
'/management/storage/create_mode',
{
method: 'POST',
body: JSON.stringify(data)
}
);
};
// 更新存储模式
export const updateStorageMode = async (
data: UpdateStorageModeRequest
): Promise<BaseResult<StorageModeResponse>> => {
return fetchApi<StorageModeResponse>(
'/management/storage/update_mode',
{
method: 'POST',
body: JSON.stringify(data)
}
);
};
// 删除存储模式
export const deleteStorageMode = async (id: number): Promise<BaseResult<boolean>> => {
return fetchApi<boolean>(
'/management/storage/delete_mode',
{
method: 'POST',
body: JSON.stringify(id) // 后端期望body中直接是id
}
);
};
// 批量删除存储模式
export const batchDeleteStorageModes = async (
ids: number[]
): Promise<BaseResult<BatchDeleteResult>> => {
return fetchApi<BatchDeleteResult>(
'/management/storage/batch_delete_modes',
{
method: 'POST',
body: JSON.stringify(ids)
}
);
};
// 获取所有可用的存储类型
export const getStorageTypes = async (): Promise<BaseResult<StorageTypeResponse[]>> => {
return fetchApi<StorageTypeResponse[]>(
'/management/storage/get_storage_types',
{ method: 'GET' }
);
};
// 获取默认存储模式ID
export const getDefaultStorageModeId = async (): Promise<BaseResult<number | null>> => {
return fetchApi<number | null>(
'/management/storage/get_default_mode_id',
{ method: 'GET' }
);
};
// 设置默认存储模式
export const setDefaultStorageMode = async (id: number): Promise<BaseResult<boolean>> => {
return fetchApi<boolean>(
`/management/storage/set_default_mode/${id}`,
{ method: 'POST' }
);
};

View File

@@ -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" | ""; export type UserRole = "Administrator" | "User" | "";
@@ -34,13 +34,6 @@ export interface AdminUpdateUserRequest {
role?: string; role?: string;
} }
// 批量删除结果
export interface BatchDeleteResult {
successCount: number;
failedCount: number;
failedIds?: number[];
}
// 用户筛选请求参数 // 用户筛选请求参数
export interface UserFilterRequest { export interface UserFilterRequest {
page?: number; page?: number;

View File

@@ -91,10 +91,17 @@ const ImageCard: React.FC<ImageCardProps> = ({
{!selectable && ( {!selectable && (
<> <>
<div className="custom-card-indicators"> <div className="custom-card-indicators">
<div className="custom-card-permission" style={{ <div className="custom-card-left-indicators">
backgroundColor: permissionTypeMap[image.permission]?.color || 'rgba(0, 0, 0, 0.6)' <div className="custom-card-permission" style={{
}}> backgroundColor: permissionTypeMap[image.permission]?.color || 'rgba(0, 0, 0, 0.6)'
{permissionTypeMap[image.permission]?.icon} {permissionTypeMap[image.permission]?.label || '公开'} }}>
{permissionTypeMap[image.permission]?.icon} {permissionTypeMap[image.permission]?.label || '公开'}
</div>
{image.storageModeName && (
<div className="custom-card-storage-mode">
{image.storageModeName}
</div>
)}
</div> </div>
<div className="custom-card-metadata"> <div className="custom-card-metadata">

View File

@@ -121,12 +121,19 @@
right: 0; right: 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; /* 确保垂直对齐 */
padding: 0 8px; padding: 0 8px;
opacity: 0; opacity: 0;
transition: opacity 0.35s ease; transition: opacity 0.35s ease;
z-index: 2; z-index: 2;
} }
.custom-card-left-indicators { /* 新增样式 */
display: flex;
align-items: center;
gap: 6px; /* 指示器之间的间距 */
}
.custom-card:hover .custom-card-indicators { .custom-card:hover .custom-card-indicators {
opacity: 1; opacity: 1;
} }
@@ -145,6 +152,20 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 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 { .custom-card-metadata {
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.6);
color: white; color: white;

View File

@@ -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<StorageModeResponse[]>([]);
const [availableStorageTypes, setAvailableStorageTypes] = useState<StorageTypeResponse[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [defaultStorageModeId, setDefaultStorageModeId] = useState<number | null>(null);
const [, setIsLoadingDefault] = useState(false);
const [settingDefaultModeId, setSettingDefaultModeId] = useState<number | null>(null);
const [filters, setFilters] = useState<StorageModeFilterRequest>({});
const [showFilters, setShowFilters] = useState(false);
const [filterForm] = Form.useForm();
const [isModalVisible, setIsModalVisible] = useState(false);
const [modalTitle, setModalTitle] = useState('');
const [editingMode, setEditingMode] = useState<StorageModeResponse | null>(null);
const [form] = Form.useForm();
const [currentStorageTypeForHelp, setCurrentStorageTypeForHelp] = useState<StorageTypeEnum | null>(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: <ExclamationCircleOutlined />,
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 (
<>
<Form.Item name={['configuration', 'BasePath']} label="基础路径 (BasePath)" rules={[{ required: true, message: '请输入基础路径' }]}>
<Input placeholder="例如: /path/to/your/uploads" />
</Form.Item>
<Form.Item name={['configuration', 'ServerUrl']} label="服务器URL (ServerUrl)" rules={[{ required: true, message: '请输入服务器URL' }]}>
<Input placeholder="例如: http://localhost:5000" />
</Form.Item>
<Form.Item name={['configuration', 'PublicBasePath']} label="公共基础路径 (PublicBasePath)" rules={[{ required: true, message: '请输入公共基础路径'}]}>
<Input placeholder="例如: /Uploads (用于拼接图片URL)" />
</Form.Item>
</>
);
case StorageTypeEnum.Telegram:
return (
<>
<Form.Item name={['configuration', 'BotToken']} label="机器人Token (BotToken)" rules={[{ required: true, message: '请输入机器人Token' }]}>
<Input.Password placeholder="您的Telegram机器人Token" />
</Form.Item>
<Form.Item name={['configuration', 'ChatId']} label="聊天ID (ChatId)" rules={[{ required: true, message: '请输入聊天ID' }]}>
<Input placeholder="目标聊天或频道的ID" />
</Form.Item>
<Form.Item name={['configuration', 'ProxyAddress']} label="代理地址 (ProxyAddress) (可选)">
<Input placeholder="例如: 127.0.0.1" />
</Form.Item>
<Form.Item name={['configuration', 'ProxyPort']} label="代理端口 (ProxyPort) (可选)">
<Input placeholder="例如: 1080" type="number" />
</Form.Item>
</>
);
case StorageTypeEnum.S3:
return (
<>
<Form.Item name={['configuration', 'AccessKey']} label="AccessKey" rules={[{ required: true, message: '请输入AccessKey' }]}>
<Input placeholder="您的S3 AccessKey" />
</Form.Item>
<Form.Item name={['configuration', 'SecretKey']} label="SecretKey" rules={[{ required: true, message: '请输入SecretKey' }]}>
<Input.Password placeholder="您的S3 SecretKey" />
</Form.Item>
<Form.Item name={['configuration', 'Endpoint']} label="Endpoint" rules={[{ required: true, message: '请输入Endpoint' }]}>
<Input placeholder="例如: s3.us-west-2.amazonaws.com" />
</Form.Item>
<Form.Item name={['configuration', 'Region']} label="区域 (Region)" rules={[{ required: true, message: '请输入区域' }]}>
<Input placeholder="例如: us-west-2" />
</Form.Item>
<Form.Item name={['configuration', 'BucketName']} label="存储桶名称 (BucketName)" rules={[{ required: true, message: '请输入存储桶名称' }]}>
<Input placeholder="您的S3存储桶名称" />
</Form.Item>
<Form.Item name={['configuration', 'UsePathStyleUrls']} label="使用路径样式URL (UsePathStyleUrls)" valuePropName="checked">
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
<Form.Item name={['configuration', 'CdnUrl']} label="CDN URL (可选)">
<Input placeholder="例如: https://cdn.example.com" />
</Form.Item>
</>
);
case StorageTypeEnum.Cos:
return (
<>
<Form.Item name={['configuration', 'Region']} label="区域 (Region)" rules={[{ required: true, message: '请输入区域' }]}>
<Input placeholder="例如: ap-guangzhou" />
</Form.Item>
<Form.Item name={['configuration', 'SecretId']} label="SecretId" rules={[{ required: true, message: '请输入SecretId' }]}>
<Input placeholder="您的腾讯云SecretId" />
</Form.Item>
<Form.Item name={['configuration', 'SecretKey']} label="SecretKey" rules={[{ required: true, message: '请输入SecretKey' }]}>
<Input.Password placeholder="您的腾讯云SecretKey" />
</Form.Item>
<Form.Item name={['configuration', 'BucketName']} label="存储桶名称 (BucketName)" rules={[{ required: true, message: '请输入存储桶名称' }]}>
<Input placeholder="格式: your-bucket-appid" />
</Form.Item>
<Form.Item name={['configuration', 'CdnUrl']} label="CDN URL (可选)">
<Input placeholder="例如: https://cdn.example.com" />
</Form.Item>
<Form.Item name={['configuration', 'PublicRead']} label="公共读 (PublicRead)" valuePropName="checked">
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</>
);
case StorageTypeEnum.WebDAV:
return (
<>
<Form.Item name={['configuration', 'ServerUrl']} label="服务器URL (ServerUrl)" rules={[{ required: true, message: '请输入WebDAV服务器URL' }]}>
<Input placeholder="例如: https://dav.example.com" />
</Form.Item>
<Form.Item name={['configuration', 'BasePath']} label="基础路径 (BasePath)">
<Input placeholder="例如: uploads (在服务器上的相对路径)" />
</Form.Item>
<Form.Item name={['configuration', 'UserName']} label="用户名 (UserName)" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="WebDAV用户名" />
</Form.Item>
<Form.Item name={['configuration', 'Password']} label="密码 (Password)" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="WebDAV密码" />
</Form.Item>
<Form.Item name={['configuration', 'PublicUrl']} label="公共URL (PublicUrl) (可选)">
<Input placeholder="例如: https://public.example.com/dav (如果WebDAV内容可通过不同URL公开访问)" />
</Form.Item>
</>
);
default:
return <Alert message="此存储类型可能不需要额外配置,或配置界面暂未实现。" type="info" showIcon />;
}
};
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', responsive: ['md'] as Breakpoint[] },
{
title: '名称',
dataIndex: 'name',
key: 'name',
render: (name: string, record: StorageModeResponse) => (
<Space>
{record.id === defaultStorageModeId && (
<Tooltip title="默认存储模式">
<StarFilled style={{ color: '#faad14' }} />
</Tooltip>
)}
{name}
</Space>
)
},
{
title: '类型', dataIndex: 'storageType', key: 'storageType',
render: (type: StorageTypeEnum) => <Tag color="blue">{StorageTypeLabels[type] || type}</Tag>,
},
{
title: '配置 (JSON)', dataIndex: 'configurationJson', key: 'configurationJson', responsive: ['lg'] as Breakpoint[],
render: (json?: string) => json ? <Tooltip title={json}><Text style={{ maxWidth: 200 }} ellipsis>{json}</Text></Tooltip> : <Text type="secondary"></Text>,
},
{
title: '启用状态', dataIndex: 'isEnabled', key: 'isEnabled',
render: (enabled: boolean) => <Tag color={enabled ? 'green' : 'red'}>{enabled ? '已启用' : '已禁用'}</Tag>,
},
{ title: '更新时间', dataIndex: 'updatedAt', key: 'updatedAt', responsive: ['lg'] as Breakpoint[], render: (date: Date) => date.toLocaleString() },
{
title: '操作', key: 'action',
render: (_: any, record: StorageModeResponse) => (
<Space size="small">
{record.isEnabled && record.id !== defaultStorageModeId && (
<Button
type="text"
icon={<StarOutlined />}
onClick={() => handleSetDefault(record.id)}
loading={settingDefaultModeId === record.id} // 使用新的行特定加载状态
title="设为默认"
>
{isMobile ? '' : '设为默认'}
</Button>
)}
<Button type="text" icon={<EditOutlined />} onClick={() => showEditModal(record)}>{isMobile ? '' : '编辑'}</Button>
<Popconfirm
title="确定删除此存储模式吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
disabled={record.id === defaultStorageModeId}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
disabled={record.id === defaultStorageModeId}
>
{isMobile ? '' : '删除'}
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Row gutter={[16, 16]} align="middle" justify="space-between">
<Col>
<Space align="center">
<DatabaseOutlined style={{ fontSize: 24 }} />
<Title level={2} style={{ margin: 0 }}></Title>
</Space>
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
</Text>
</Col>
<Col>
{defaultStorageModeId && (
<Alert
type="info"
message={
<Space>
<StarFilled style={{ color: '#faad14' }} />
<span>ID: {defaultStorageModeId}</span>
</Space>
}
style={{ padding: '4px 12px' }}
/>
)}
{!defaultStorageModeId && (
<Alert
type="warning"
message="未设置默认存储模式"
description="请选择一个启用的存储模式设为默认"
showIcon
/>
)}
</Col>
</Row>
<Card style={{ marginTop: 16 }}>
<Row gutter={[16, 16]} justify="space-between" style={{ marginBottom: 16 }}>
<Col xs={24} sm={14} md={16}>
<Space wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={showCreateModal}></Button>
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete} disabled={selectedRowKeys.length === 0}></Button>
<Button icon={<ReloadOutlined />} onClick={() => fetchStorageModes(currentPage, pageSize, filters)}></Button>
<Button icon={<FilterOutlined />} onClick={() => setShowFilters(!showFilters)} type={showFilters ? 'primary' : 'default'}></Button>
</Space>
</Col>
<Col xs={24} sm={10} md={8}>
<Input.Search placeholder="搜索模式名称" allowClear enterButton={<SearchOutlined />} onSearch={handleQuickSearch} />
</Col>
</Row>
{showFilters && (
<Card size="small" style={{ marginBottom: 16, backgroundColor: '#fafafa' }}>
<Form form={filterForm} layout="inline" onFinish={handleFilter}>
<Form.Item name="searchQuery" label="名称">
<Input placeholder="模式名称" style={{ width: 150 }} />
</Form.Item>
<Form.Item name="storageType" label="类型">
<Select placeholder="选择类型" style={{ width: 150 }} allowClear>
{availableStorageTypes.map(st => <Option key={st.value} value={st.value}>{st.name}</Option>)}
</Select>
</Form.Item>
<Form.Item name="isEnabled" label="状态">
<Select placeholder="选择状态" style={{ width: 120 }} allowClear>
<Option value={true}></Option>
<Option value={false}></Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}></Button>
<Button icon={<ClearOutlined />} onClick={handleClearFilters}></Button>
</Space>
</Form.Item>
</Form>
</Card>
)}
<Table
rowKey="id"
columns={columns}
dataSource={storageModes}
loading={loading}
pagination={{
current: currentPage, pageSize: pageSize, total: total,
showSizeChanger: true, showQuickJumper: true, onChange: handlePageChange,
showTotal: (t) => `${t} 条记录`,
}}
rowSelection={{ selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys) }}
size={isMobile ? "small" : "middle"}
scroll={{ x: 'max-content' }}
/>
</Card>
<Modal
title={modalTitle}
open={isModalVisible}
onOk={handleModalOk}
onCancel={() => setIsModalVisible(false)}
okText={editingMode ? "更新" : "创建"}
cancelText="取消"
width={isMobile ? '90%' : 700}
>
<Form form={form} layout="vertical" initialValues={{ isEnabled: true, configuration: {} }}>
<Form.Item name="name" label="模式名称" rules={[{ required: true, message: '请输入模式名称' }]}>
<Input placeholder="例如:主图片存储、备份存储等" />
</Form.Item>
<Form.Item name="storageType" label="存储类型" rules={[{ required: true, message: '请选择存储类型' }]}>
<Select
placeholder="选择一个存储类型"
onChange={(value) => {
setCurrentStorageTypeForHelp(value as StorageTypeEnum);
form.setFieldsValue({ configuration: {} });
}}
>
{availableStorageTypes.map(st => <Option key={st.value} value={st.value}>{StorageTypeLabels[st.value as StorageTypeEnum] || st.name}</Option>)}
</Select>
</Form.Item>
{renderDynamicConfigFields(currentStorageTypeForHelp)}
<Form.Item name="isEnabled" label="启用状态" valuePropName="checked">
<Switch checkedChildren="已启用" unCheckedChildren="已禁用" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default StorageManagementPage;

View File

@@ -1,16 +1,16 @@
import React from 'react'; 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 { import {
ApiOutlined, RocketOutlined, PictureOutlined, SaveOutlined, ApiOutlined, RocketOutlined, PictureOutlined, SaveOutlined,
SafetyCertificateOutlined, LockOutlined, GlobalOutlined, SettingOutlined, SafetyCertificateOutlined, LockOutlined, GlobalOutlined, SettingOutlined,
CloudServerOutlined, DatabaseOutlined, UploadOutlined} from '@ant-design/icons'; DatabaseOutlined, UploadOutlined
} from '@ant-design/icons';
import ConfigFormItem from './ConfigFormItem'; import ConfigFormItem from './ConfigFormItem';
import ConfigSection from './ConfigSection'; import ConfigSection from './ConfigSection';
import VectorDbConfig from './VectorDbConfig'; import VectorDbConfig from './VectorDbConfig';
const { TabPane } = Tabs; const { TabPane } = Tabs;
const { Option } = Select; // const { Option } = Select; // Removed
const { Title, Paragraph } = Typography;
interface ConfigStructure { interface ConfigStructure {
[key: string]: { [key: string]: {
@@ -24,17 +24,13 @@ interface ConfigTabsProps {
isMobile: boolean; isMobile: boolean;
activeKey: string; activeKey: string;
onTabChange: (key: string) => void; onTabChange: (key: string) => void;
storageType: string;
onStorageTypeChange: (type: string) => void;
formsMap: Record<string, any>; formsMap: Record<string, any>;
allDescriptions: Record<string, Record<string, string>>; allDescriptions: Record<string, Record<string, string>>;
onSaveSingleConfig: (formInstance: any, groupName: string, key: string) => Promise<void>; onSaveSingleConfig: (formInstance: any, groupName: string, key: string) => Promise<void>;
onSaveAllForGroup: (formInstance: any, groupName: string, itemKeys: string[]) => Promise<void>; onSaveAllForGroup: (formInstance: any, groupName: string, itemKeys: string[]) => Promise<void>;
onBaseSaveConfig: (group: string, key: string, value: string) => Promise<boolean>; onBaseSaveConfig: (group: string, key: string, value: string) => Promise<boolean>;
setConfigs: React.Dispatch<React.SetStateAction<ConfigStructure>>; setConfigs: React.Dispatch<React.SetStateAction<ConfigStructure>>;
storageOptions: Array<{ value: string; label: string; icon: React.ReactNode; }>; // imageQualityOptions: Array<{ value: string; label: string; description: string; }>; // Removed
imageFormatOptions: Array<{ value: string; label: string; description: string; }>;
imageQualityOptions: Array<{ value: string; label: string; description: string; }>;
} }
const ConfigTabs: React.FC<ConfigTabsProps> = ({ const ConfigTabs: React.FC<ConfigTabsProps> = ({
@@ -43,17 +39,13 @@ const ConfigTabs: React.FC<ConfigTabsProps> = ({
isMobile, isMobile,
activeKey, activeKey,
onTabChange, onTabChange,
storageType,
onStorageTypeChange,
formsMap, formsMap,
allDescriptions, allDescriptions,
onSaveSingleConfig, onSaveSingleConfig,
onSaveAllForGroup, onSaveAllForGroup,
onBaseSaveConfig, onBaseSaveConfig,
setConfigs, setConfigs,
storageOptions, // imageQualityOptions, // Removed
imageFormatOptions,
imageQualityOptions,
}) => { }) => {
const renderConfigFormItems = (formInstance: any, groupName: string, itemKeys: string[]) => { const renderConfigFormItems = (formInstance: any, groupName: string, itemKeys: string[]) => {
@@ -306,245 +298,93 @@ const ConfigTabs: React.FC<ConfigTabsProps> = ({
) )
}, },
{ {
key: 'Storage', key: 'Upload',
label: '存储设置', label: '上传设置',
icon: <CloudServerOutlined />, icon: <UploadOutlined />,
children: ( children: (
<> <>
<ConfigSection <ConfigSection
title="存储类型配置" title="上传参数配置"
icon={<DatabaseOutlined />}
description="配置系统默认使用的文件存储方式"
isMobile={isMobile}
>
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fit, minmax(300px, 1fr))',
gap: isMobile ? 12 : 16,
marginBottom: 0
}}>
<div>
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
</div>
<Select
value={configs.Storage?.DefaultStorage || 'Local'}
onChange={(value) => onBaseSaveConfig('Storage', 'DefaultStorage', value)}
style={{ width: '100%' }}
size="large"
placeholder="选择登录用户的默认存储方式"
optionLabelProp="label"
>
{storageOptions.map(option => (
<Option key={option.value} value={option.value} label={option.label}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{option.icon}
<span style={{ marginLeft: 8 }}>{option.label}</span>
</div>
</Option>
))}
</Select>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
{allDescriptions.Storage?.DefaultStorage}
</div>
</div>
<div>
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
</div>
<Select
value={configs.Storage?.AnonymousDefaultStorage || 'Local'}
onChange={(value) => onBaseSaveConfig('Storage', 'AnonymousDefaultStorage', value)}
style={{ width: '100%' }}
size="large"
placeholder="选择匿名用户的默认存储方式"
optionLabelProp="label"
>
{storageOptions.map(option => (
<Option key={option.value} value={option.value} label={option.label}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{option.icon}
<span style={{ marginLeft: 8 }}>{option.label}</span>
</div>
</Option>
))}
</Select>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
{allDescriptions.Storage?.AnonymousDefaultStorage}
</div>
</div>
</div>
</ConfigSection>
<ConfigSection
title="上传设置配置"
icon={<UploadOutlined />} icon={<UploadOutlined />}
description="配置文件上传处理方式和图片转换参数" description="配置文件上传处理方式和图片转换参数"
isMobile={isMobile} isMobile={isMobile}
> >
<div style={{ <div style={{
display: 'grid', display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fit, minmax(300px, 1fr))', gridTemplateColumns: '1fr',
gap: isMobile ? 12 : 16, gap: isMobile ? 20 : 24,
marginBottom: 0 marginBottom: 0
}}> }}>
<div> <div>
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}> <div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
(px)
</div> </div>
<Select <InputNumber
value={configs.Upload?.DefaultImageFormat || 'Original'} min={100}
onChange={(value) => onBaseSaveConfig('Upload', 'DefaultImageFormat', value)} max={1000}
step={50}
value={parseInt(configs.Upload?.ThumbnailMaxWidth || '400', 10)}
onChange={(value) => {
if (value !== null) {
onBaseSaveConfig('Upload', 'ThumbnailMaxWidth', value.toString())
}
}}
style={{ width: '100%' }} style={{ width: '100%' }}
size="large" />
placeholder="选择上传图片的默认处理格式"
optionLabelProp="label"
>
{imageFormatOptions.map(option => (
<Option key={option.value} value={option.value} label={option.label}>
<div>
<div>{option.label}</div>
<div style={{ fontSize: '12px', color: '#999' }}>{option.description}</div>
</div>
</Option>
))}
</Select>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}> <div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
{allDescriptions.Upload?.DefaultImageFormat} {allDescriptions.Upload?.ThumbnailMaxWidth}
</div> </div>
</div> </div>
<div> <div>
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}> <div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
</div> </div>
<Select <Slider
value={configs.Upload?.DefaultImageQuality || '95'} min={30}
onChange={(value) => onBaseSaveConfig('Upload', 'DefaultImageQuality', value)} max={90}
style={{ width: '100%' }} step={5}
size="large" value={parseInt(configs.Upload?.ThumbnailCompressionQuality || '75', 10)}
placeholder="选择图片压缩质量" onChange={(value) => onBaseSaveConfig('Upload', 'ThumbnailCompressionQuality', value.toString())}
optionLabelProp="label" style={{ margin: isMobile ? '0 5px' : '0 10px' }}
> tooltip={{
{imageQualityOptions.map(option => ( formatter: value => `${value}%`
<Option key={option.value} value={option.value} label={option.label}> }}
<div> marks={{
<div>{option.label}</div> 30: '30%',
<div style={{ fontSize: '12px', color: '#999' }}>{option.description}</div> 60: '60%',
</div> 90: '90%'
</Option> }}
))} />
</Select> <div style={{ fontSize: 12, color: '#999', marginTop: 16, textAlign: 'center' }}>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}> {allDescriptions.Upload?.ThumbnailCompressionQuality}
{allDescriptions.Upload?.DefaultImageQuality}
</div> </div>
</div> </div>
</div>
</ConfigSection>
<ConfigSection <div>
title="存储服务配置" <div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
icon={<CloudServerOutlined />}
description="配置各种外部存储服务的连接参数"
isMobile={isMobile}
>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
</div>
<Select
value={storageType}
onChange={onStorageTypeChange}
style={{ width: isMobile ? '100%' : '300px' }}
size="large"
placeholder="选择需要配置的存储服务类型"
optionLabelProp="label"
>
{storageOptions.map(option => (
<Option key={option.value} value={option.value} label={option.label}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{option.icon}
<span style={{ marginLeft: 8 }}>{option.label}</span>
</div>
</Option>
))}
</Select>
<div style={{ fontSize: 12, color: '#999', marginTop: 4, marginBottom: 16 }}>
</div>
</div>
<div style={{ border: '1px solid #f0f0f0', borderRadius: 6, padding: isMobile ? 12 : 16, backgroundColor: '#fafafa' }}>
{storageType === 'Local' && (
<div style={{ textAlign: 'center', color: '#999', padding: '30px 0' }}>
<DatabaseOutlined style={{ fontSize: 32, color: '#52c41a', marginBottom: 16 }} />
<Title level={5}></Title>
<Paragraph type="secondary"></Paragraph>
</div> </div>
)} <Slider
{storageType === 'Telegram' && ( min={50}
<Form form={formsMap.TelegramStorage} layout="vertical" size={isMobile ? "middle" : "large"}> max={100}
{renderConfigFormItems(formsMap.TelegramStorage, "Storage", ["TelegramStorageBotToken", "TelegramStorageChatId", "TelegramProxyAddress", "TelegramProxyPort", "TelegramProxyUsername", "TelegramProxyPassword"])} step={5}
<Divider style={{ margin: '12px 0 20px' }} /> value={parseInt(configs.Upload?.HighQualityImageCompressionQuality || '95', 10)}
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}> onChange={(value) => onBaseSaveConfig('Upload', 'HighQualityImageCompressionQuality', value.toString())}
<Button style={{ margin: isMobile ? '0 5px' : '0 10px' }}
type="primary" tooltip={{
icon={<SaveOutlined />} formatter: value => `${value}%`
onClick={() => onSaveAllForGroup(formsMap.TelegramStorage, "Storage", ["TelegramStorageBotToken", "TelegramStorageChatId", "TelegramProxyAddress", "TelegramProxyPort", "TelegramProxyUsername", "TelegramProxyPassword"])} }}
style={{ width: isMobile ? '100%' : '240px' }} marks={{
> 50: '50%',
Telegram 75: '75%',
</Button> 100: '100%'
</Form.Item> }}
</Form> />
)} <div style={{ fontSize: 12, color: '#999', marginTop: 16, textAlign: 'center' }}>
{storageType === 'S3' && ( {allDescriptions.Upload?.HighQualityImageCompressionQuality}
<Form form={formsMap.S3Storage} layout="vertical" size={isMobile ? "middle" : "large"}> </div>
{renderConfigFormItems(formsMap.S3Storage, "Storage", ["S3StorageAccessKey", "S3StorageSecretKey", "S3StorageBucketName", "S3StorageRegion", "S3StorageEndpoint", "S3StorageCdnUrl", "S3StorageUsePathStyleUrls"])} </div>
<Divider style={{ margin: '12px 0 20px' }} />
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onSaveAllForGroup(formsMap.S3Storage, "Storage", ["S3StorageAccessKey", "S3StorageSecretKey", "S3StorageBucketName", "S3StorageRegion", "S3StorageEndpoint", "S3StorageCdnUrl", "S3StorageUsePathStyleUrls"])}
style={{ width: isMobile ? '100%' : '240px' }}
>
S3
</Button>
</Form.Item>
</Form>
)}
{storageType === 'Cos' && (
<Form form={formsMap.CosStorage} layout="vertical" size={isMobile ? "middle" : "large"}>
{renderConfigFormItems(formsMap.CosStorage, "Storage", ["CosStorageSecretId", "CosStorageSecretKey", "CosStorageToken", "CosStorageBucketName", "CosStorageRegion", "CosStorageCdnUrl"])}
<Divider style={{ margin: '12px 0 20px' }} />
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onSaveAllForGroup(formsMap.CosStorage, "Storage", ["CosStorageSecretId", "CosStorageSecretKey", "CosStorageToken", "CosStorageBucketName", "CosStorageRegion", "CosStorageCdnUrl"])}
style={{ width: isMobile ? '100%' : '240px' }}
>
COS
</Button>
</Form.Item>
</Form>
)}
{storageType === 'WebDAV' && (
<Form form={formsMap.WebDAVStorage} layout="vertical" size={isMobile ? "middle" : "large"}>
{renderConfigFormItems(formsMap.WebDAVStorage, "Storage", ["WebDAVServerUrl", "WebDAVUserName", "WebDAVPassword", "WebDAVBasePath", "WebDAVPublicUrl"])}
<Divider style={{ margin: '12px 0 20px' }} />
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onSaveAllForGroup(formsMap.WebDAVStorage, "Storage", ["WebDAVServerUrl", "WebDAVUserName", "WebDAVPassword", "WebDAVBasePath", "WebDAVPublicUrl"])}
style={{ width: isMobile ? '100%' : '240px' }}
>
WebDAV
</Button>
</Form.Item>
</Form>
)}
</div> </div>
</ConfigSection> </ConfigSection>
</> </>

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useState, useRef, useCallback } from 'react'; import React, { useEffect, useState, useRef, useCallback } from 'react';
import { Card, message, Spin, Button, Upload, Modal, Space, Tooltip, Form, Typography, notification } from 'antd'; import { Card, message, Spin, Button, Upload, Modal, Space, Tooltip, Form, Typography, notification } from 'antd';
import { import {
CloudOutlined, DatabaseOutlined, CloudServerOutlined, GlobalOutlined,
DownloadOutlined, UploadOutlined, QuestionCircleOutlined, DownloadOutlined, UploadOutlined, QuestionCircleOutlined,
SettingOutlined, SettingOutlined,
CheckCircleOutlined CheckCircleOutlined
@@ -46,37 +45,10 @@ const allDescriptions: Record<string, Record<string, string>> = {
ServerUrl: '服务器URL', ServerUrl: '服务器URL',
MaxConcurrentTasks: '后台任务最大并发处理数量 (例如: 图像分析、标签生成等)' 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: { Upload: {
DefaultImageFormat: '上传图片时的默认处理格式,选择合适的格式可以优化存储和显示', HighQualityImageCompressionQuality: '高清图片的压缩质量,越高图片质量越好但文件越大。范围 50-100。',
DefaultImageQuality: '适用于JPEG和WebP格式的图片质量设置越高图片质量越好但文件越大' ThumbnailMaxWidth: '缩略图的最大宽度(像素),例如设置为 400。',
ThumbnailCompressionQuality: '缩略图的压缩质量,用于平衡文件大小和清晰度。范围 30-90。'
} }
}; };
@@ -86,7 +58,6 @@ const System: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [configs, setConfigs] = useState<ConfigStructure>({}); const [configs, setConfigs] = useState<ConfigStructure>({});
const [activeKey, setActiveKey] = useState('AI'); const [activeKey, setActiveKey] = useState('AI');
const [storageType, setStorageType] = useState('Telegram');
const [backupLoading, setBackupLoading] = useState(false); const [backupLoading, setBackupLoading] = useState(false);
const [restoreLoading, setRestoreLoading] = useState(false); const [restoreLoading, setRestoreLoading] = useState(false);
const [restoreModalVisible, setRestoreModalVisible] = useState(false); const [restoreModalVisible, setRestoreModalVisible] = useState(false);
@@ -100,10 +71,6 @@ const System: React.FC = () => {
const [jwtForm] = Form.useForm(); const [jwtForm] = Form.useForm();
const [authForm] = Form.useForm(); const [authForm] = Form.useForm();
const [appSettingsForm] = 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 [uploadForm] = Form.useForm();
const formsMap: Record<string, any> = { const formsMap: Record<string, any> = {
@@ -111,10 +78,6 @@ const System: React.FC = () => {
Jwt: jwtForm, Jwt: jwtForm,
Authentication: authForm, Authentication: authForm,
AppSettings: appSettingsForm, AppSettings: appSettingsForm,
TelegramStorage: telegramForm,
S3Storage: s3Form,
CosStorage: cosForm,
WebDAVStorage: webDAVForm,
Upload: uploadForm, Upload: uploadForm,
}; };
@@ -145,20 +108,11 @@ const System: React.FC = () => {
setConfigs(configGroups); setConfigs(configGroups);
setSecretFields(secretFieldsMap); setSecretFields(secretFieldsMap);
if (configGroups.Storage?.DefaultStorage) {
setStorageType(configGroups.Storage.DefaultStorage);
}
// 更高效地更新表单值 // 更高效地更新表单值
Object.keys(configGroups).forEach(group => { Object.keys(configGroups).forEach(group => {
let formInstanceKey = group; let formInstanceKey = group;
if (group === "Storage") {
} else if (group.endsWith("Storage") && formsMap[group]) {
formInstanceKey = group;
}
const formInstance = formsMap[formInstanceKey];
const formInstance = formsMap[formInstanceKey] || (group === "Storage" ? formsMap[`${configGroups.Storage.DefaultStorage}Storage`] : null);
if (formInstance) { if (formInstance) {
const initialGroupValues: Record<string, string> = {}; const initialGroupValues: Record<string, string> = {};
@@ -456,34 +410,6 @@ const System: React.FC = () => {
} }
}; };
// 存储类型选项
const storageOptions = [
{ value: 'Local', label: '本地存储', icon: <DatabaseOutlined style={{ color: '#52c41a' }} /> },
{ value: 'Telegram', label: 'Telegram 频道', icon: <CloudOutlined style={{ color: '#0088cc' }} /> },
{ value: 'S3', label: '亚马逊 S3', icon: <CloudServerOutlined style={{ color: '#ff9900' }} /> },
{ value: 'Cos', label: '腾讯云 COS', icon: <CloudServerOutlined style={{ color: '#00a4ff' }} /> },
{ value: 'WebDAV', label: 'WebDAV 存储', icon: <GlobalOutlined style={{ color: '#1890ff' }} /> },
];
// 上传格式选项
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(() => { useEffect(() => {
fetchConfigs(); fetchConfigs();
}, []); }, []);
@@ -546,17 +472,12 @@ const System: React.FC = () => {
isMobile={isMobile} isMobile={isMobile}
activeKey={activeKey} activeKey={activeKey}
onTabChange={setActiveKey} onTabChange={setActiveKey}
storageType={storageType}
onStorageTypeChange={setStorageType}
formsMap={formsMap} formsMap={formsMap}
allDescriptions={allDescriptions} allDescriptions={allDescriptions}
onSaveSingleConfig={handleSaveSingleConfig} onSaveSingleConfig={handleSaveSingleConfig}
onSaveAllForGroup={handleSaveAllForGroup} onSaveAllForGroup={handleSaveAllForGroup}
onBaseSaveConfig={baseSaveConfig} onBaseSaveConfig={baseSaveConfig}
setConfigs={setConfigs} setConfigs={setConfigs}
storageOptions={storageOptions}
imageFormatOptions={imageFormatOptions}
imageQualityOptions={imageQualityOptions}
/> />
)} )}

View File

@@ -24,6 +24,7 @@ import UserManagement from '../pages/admin/users/Index';
import PictureManagement from '../pages/admin/pictures/Index'; import PictureManagement from '../pages/admin/pictures/Index';
import UserDetail from '../pages/admin/users/UserDetail'; import UserDetail from '../pages/admin/users/UserDetail';
import AdminLogManagement from '../pages/admin/log/Index'; import AdminLogManagement from '../pages/admin/log/Index';
import StorageManagementPage from '../pages/admin/storage/StorageManagement';
export interface RouteConfig { export interface RouteConfig {
path: string; path: string;
@@ -182,6 +183,17 @@ const routes: RouteConfig[] = [
breadcrumb: { breadcrumb: {
title: '日志中心' title: '日志中心'
} }
},
{
path: 'storage',
key: 'admin-storage',
icon: <FileTextOutlined />,
label: '存储配置',
element: <StorageManagementPage />,
area: 'admin',
breadcrumb: {
title: '存储配置'
}
}, },
{ {
path: 'system', path: 'system',