mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-08-28 03:29:35 +08:00
feat(background): add visual recognition task and refactor picture processing
This commit is contained in:
@@ -40,6 +40,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddSingleton<WebDavStorageProvider>();
|
services.AddSingleton<WebDavStorageProvider>();
|
||||||
services.AddSingleton<IStorageService, StorageService>();
|
services.AddSingleton<IStorageService, StorageService>();
|
||||||
services.AddSingleton<PictureTaskProcessor>();
|
services.AddSingleton<PictureTaskProcessor>();
|
||||||
|
services.AddSingleton<VisualRecognitionTaskProcessor>();
|
||||||
services.AddSingleton<IDatabaseInitializer, DatabaseInitializer>();
|
services.AddSingleton<IDatabaseInitializer, DatabaseInitializer>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Uploads\" />
|
<Folder Include="Uploads\" />
|
||||||
<Folder Include="Uploads\2025\" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Foxel.Models.DataBase
|
|||||||
public enum TaskType
|
public enum TaskType
|
||||||
{
|
{
|
||||||
PictureProcessing = 0,
|
PictureProcessing = 0,
|
||||||
|
VisualRecognition = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum TaskExecutionStatus
|
public enum TaskExecutionStatus
|
||||||
|
|||||||
@@ -9,8 +9,16 @@ public class Picture : BaseModel
|
|||||||
{
|
{
|
||||||
[StringLength(255)] public string Name { get; set; } = string.Empty;
|
[StringLength(255)] public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the high-definition (possibly format-converted) image.
|
||||||
|
/// </summary>
|
||||||
[StringLength(1024)] public string Path { get; set; } = string.Empty;
|
[StringLength(1024)] public string Path { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the original, untouched uploaded file.
|
||||||
|
/// </summary>
|
||||||
|
[StringLength(1024)] public string OriginalPath { get; set; } = string.Empty;
|
||||||
|
|
||||||
[StringLength(1024)] public string? ThumbnailPath { get; set; } = string.Empty;
|
[StringLength(1024)] public string? ThumbnailPath { get; set; } = string.Empty;
|
||||||
|
|
||||||
[StringLength(2000)] public string Description { get; set; } = string.Empty;
|
[StringLength(2000)] public string Description { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public record PictureResponse
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string? Path { get; set; }
|
public string? Path { get; set; }
|
||||||
public string? ThumbnailPath { get; set; }
|
public string? ThumbnailPath { get; set; }
|
||||||
|
public string? OriginalPath { get; set; }
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public List<string>? Tags { get; set; }
|
public List<string>? Tags { get; set; }
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Foxel.Models.DataBase;
|
using Foxel.Models.DataBase;
|
||||||
@@ -25,19 +24,17 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
|||||||
IConfigService configuration,
|
IConfigService configuration,
|
||||||
ILogger<BackgroundTaskQueue> logger)
|
ILogger<BackgroundTaskQueue> logger)
|
||||||
{
|
{
|
||||||
_serviceProvider = serviceProvider; // Keep IServiceProvider to resolve processors
|
_serviceProvider = serviceProvider;
|
||||||
_contextFactory = contextFactory;
|
_contextFactory = contextFactory;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_processingTasks = new List<Task>();
|
_processingTasks = new List<Task>();
|
||||||
_maxConcurrentTasks = configuration.GetValueAsync("BackgroundTasks:MaxConcurrentTasks", 10).Result; // 保持原有逻辑
|
_maxConcurrentTasks = configuration.GetValueAsync("BackgroundTasks:MaxConcurrentTasks", 10).Result; // 保持原有逻辑
|
||||||
_signal = new SemaphoreSlim(_maxConcurrentTasks);
|
_signal = new SemaphoreSlim(_maxConcurrentTasks);
|
||||||
var options = new BoundedChannelOptions(10000) // 保持原有逻辑
|
var options = new BoundedChannelOptions(10000)
|
||||||
{
|
{
|
||||||
FullMode = BoundedChannelFullMode.Wait
|
FullMode = BoundedChannelFullMode.Wait
|
||||||
};
|
};
|
||||||
_queue = Channel.CreateBounded<Guid>(options);
|
_queue = Channel.CreateBounded<Guid>(options);
|
||||||
|
|
||||||
// 启动处理器,确保在服务启动时就开始处理队列
|
|
||||||
StartProcessor();
|
StartProcessor();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +48,7 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
|||||||
throw new KeyNotFoundException($"找不到 PictureId: {pictureId} 的图片");
|
throw new KeyNotFoundException($"找不到 PictureId: {pictureId} 的图片");
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload = new PictureProcessingPayload
|
var payload = new Processors.PictureProcessingPayload
|
||||||
{
|
{
|
||||||
PictureId = pictureId,
|
PictureId = pictureId,
|
||||||
OriginalFilePath = originalFilePath,
|
OriginalFilePath = originalFilePath,
|
||||||
@@ -74,7 +71,39 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
|||||||
await _queue.Writer.WriteAsync(backgroundTask.Id);
|
await _queue.Writer.WriteAsync(backgroundTask.Id);
|
||||||
_logger.LogInformation("图片处理任务已加入队列: TaskId={TaskId}, PictureId={PictureId}", backgroundTask.Id, pictureId);
|
_logger.LogInformation("图片处理任务已加入队列: TaskId={TaskId}, PictureId={PictureId}", backgroundTask.Id, pictureId);
|
||||||
|
|
||||||
StartProcessor();
|
StartProcessor(); // Ensure processor is running or starts for new items
|
||||||
|
|
||||||
|
return backgroundTask.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guid> QueueVisualRecognitionTaskAsync(VisualRecognitionPayload payload)
|
||||||
|
{
|
||||||
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
|
// Optionally, validate picture existence again, though PictureTaskProcessor should ensure it.
|
||||||
|
var picture = await dbContext.Pictures.FindAsync(payload.PictureId);
|
||||||
|
if (picture == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("无法为不存在的图片 PictureId: {PictureId} 创建视觉识别任务", payload.PictureId);
|
||||||
|
throw new KeyNotFoundException($"尝试为 PictureId: {payload.PictureId} 创建视觉识别任务时找不到图片");
|
||||||
|
}
|
||||||
|
|
||||||
|
var backgroundTask = new BackgroundTask
|
||||||
|
{
|
||||||
|
Type = TaskType.VisualRecognition, // New TaskType
|
||||||
|
Payload = JsonSerializer.Serialize(payload),
|
||||||
|
UserId = payload.UserIdForPicture, // Comes from the payload
|
||||||
|
RelatedEntityId = payload.PictureId,
|
||||||
|
Status = TaskExecutionStatus.Pending,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.BackgroundTasks.Add(backgroundTask);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
await _queue.Writer.WriteAsync(backgroundTask.Id);
|
||||||
|
_logger.LogInformation("视觉识别任务已加入队列: TaskId={TaskId}, PictureId={PictureId}", backgroundTask.Id, payload.PictureId);
|
||||||
|
|
||||||
|
StartProcessor(); // Ensure processor is running or starts for new items
|
||||||
|
|
||||||
return backgroundTask.Id;
|
return backgroundTask.Id;
|
||||||
}
|
}
|
||||||
@@ -96,13 +125,29 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
|||||||
var picture = await dbContext.Pictures.FindAsync(task.RelatedEntityId.Value);
|
var picture = await dbContext.Pictures.FindAsync(task.RelatedEntityId.Value);
|
||||||
if (picture != null)
|
if (picture != null)
|
||||||
{
|
{
|
||||||
taskName = picture.Name;
|
taskName = $"图片处理: {picture.Name}";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
taskName = "图片处理 (图片信息丢失)";
|
taskName = "图片处理 (图片信息丢失)";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (task.Type == TaskType.VisualRecognition && task.RelatedEntityId.HasValue) // Added for VisualRecognition
|
||||||
|
{
|
||||||
|
var picture = await dbContext.Pictures.FindAsync(task.RelatedEntityId.Value);
|
||||||
|
if (picture != null)
|
||||||
|
{
|
||||||
|
taskName = $"视觉识别: {picture.Name}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
taskName = "视觉识别 (图片信息丢失)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
taskName = $"任务: {task.Id} ({task.Type})"; // Generic name
|
||||||
|
}
|
||||||
|
|
||||||
statusList.Add(new TaskDetailsDto
|
statusList.Add(new TaskDetailsDto
|
||||||
{
|
{
|
||||||
@@ -156,30 +201,28 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
|||||||
{
|
{
|
||||||
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
var unfinishedTasks = await dbContext.BackgroundTasks
|
var unfinishedTasks = await dbContext.BackgroundTasks
|
||||||
.Where(bt => bt.Type == TaskType.PictureProcessing &&
|
.Where(bt => (bt.Type == TaskType.PictureProcessing || bt.Type == TaskType.VisualRecognition) && // Added VisualRecognition
|
||||||
(bt.Status == TaskExecutionStatus.Pending || bt.Status == TaskExecutionStatus.Processing))
|
(bt.Status == TaskExecutionStatus.Pending || bt.Status == TaskExecutionStatus.Processing))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
if (unfinishedTasks.Any())
|
if (unfinishedTasks.Any())
|
||||||
{
|
{
|
||||||
_logger.LogInformation("正在恢复 {Count} 个未完成的图片处理任务", unfinishedTasks.Count);
|
_logger.LogInformation("正在恢复 {Count} 个未完成的任务", unfinishedTasks.Count);
|
||||||
foreach (var task in unfinishedTasks)
|
foreach (var task in unfinishedTasks)
|
||||||
{
|
{
|
||||||
// 确保任务状态在数据库中被重置为 Pending,以防上次运行时停在 Processing 状态
|
|
||||||
if (task.Status == TaskExecutionStatus.Processing)
|
if (task.Status == TaskExecutionStatus.Processing)
|
||||||
{
|
{
|
||||||
task.Status = TaskExecutionStatus.Pending;
|
task.Status = TaskExecutionStatus.Pending;
|
||||||
task.StartedAt = null; // 重置开始时间
|
task.StartedAt = null;
|
||||||
// 保留 Progress 和 ErrorMessage 以供参考
|
|
||||||
}
|
}
|
||||||
await _queue.Writer.WriteAsync(task.Id);
|
await _queue.Writer.WriteAsync(task.Id);
|
||||||
_logger.LogInformation("已恢复图片处理任务到队列: TaskId={TaskId}, RelatedEntityId={RelatedEntityId}", task.Id, task.RelatedEntityId);
|
_logger.LogInformation("已恢复任务到队列: TaskId={TaskId}, Type={TaskType}, RelatedEntityId={RelatedEntityId}", task.Id, task.Type, task.RelatedEntityId);
|
||||||
}
|
}
|
||||||
await dbContext.SaveChangesAsync(); // 保存状态更改
|
await dbContext.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogInformation("没有需要恢复的图片处理任务");
|
_logger.LogInformation("没有需要恢复的任务");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -243,7 +286,9 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
|||||||
case TaskType.PictureProcessing:
|
case TaskType.PictureProcessing:
|
||||||
processor = scope.ServiceProvider.GetRequiredService<PictureTaskProcessor>();
|
processor = scope.ServiceProvider.GetRequiredService<PictureTaskProcessor>();
|
||||||
break;
|
break;
|
||||||
// Future task types can be added here
|
case TaskType.VisualRecognition: // Added case for VisualRecognition
|
||||||
|
processor = scope.ServiceProvider.GetRequiredService<VisualRecognitionTaskProcessor>();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
_logger.LogError("未找到任务类型 {TaskType} 的处理器: TaskId={TaskId}", taskToCheck.Type, taskToCheck.Id);
|
_logger.LogError("未找到任务类型 {TaskType} 的处理器: TaskId={TaskId}", taskToCheck.Type, taskToCheck.Id);
|
||||||
await MarkTaskAsFailedByQueue(taskToCheck.Id, $"未找到任务类型 {taskToCheck.Type} 的处理器。");
|
await MarkTaskAsFailedByQueue(taskToCheck.Id, $"未找到任务类型 {taskToCheck.Type} 的处理器。");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Foxel.Models.DataBase;
|
using Foxel.Models.DataBase;
|
||||||
|
using Foxel.Services.Background.Processors; // For VisualRecognitionPayload
|
||||||
|
|
||||||
namespace Foxel.Services.Background;
|
namespace Foxel.Services.Background;
|
||||||
|
|
||||||
@@ -8,13 +9,20 @@ namespace Foxel.Services.Background;
|
|||||||
public interface IBackgroundTaskQueue
|
public interface IBackgroundTaskQueue
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 将图片处理任务添加到队列
|
/// 将图片处理任务(元数据和缩略图)添加到队列
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pictureId">图片ID</param>
|
/// <param name="pictureId">图片ID</param>
|
||||||
/// <param name="originalFilePath">原始图片路径</param>
|
/// <param name="originalFilePath">原始图片路径</param>
|
||||||
/// <returns>任务ID</returns>
|
/// <returns>任务ID</returns>
|
||||||
Task<Guid> QueuePictureProcessingTaskAsync(int pictureId, string originalFilePath);
|
Task<Guid> QueuePictureProcessingTaskAsync(int pictureId, string originalFilePath);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将视觉识别任务添加到队列
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="payload">视觉识别任务的Payload</param>
|
||||||
|
/// <returns>任务ID</returns>
|
||||||
|
Task<Guid> QueueVisualRecognitionTaskAsync(VisualRecognitionPayload payload);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取用户的所有任务状态 (目前主要指图片处理任务)
|
/// 获取用户的所有任务状态 (目前主要指图片处理任务)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -6,9 +6,23 @@ 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;
|
||||||
|
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 int PictureId { get; set; }
|
||||||
|
public string OriginalFilePath { get; set; } = string.Empty;
|
||||||
|
public int? UserIdForPicture { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class VisualRecognitionPayload // Define new payload
|
||||||
|
{
|
||||||
|
public int PictureId { get; set; }
|
||||||
|
public int? UserIdForPicture { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class PictureTaskProcessor : ITaskProcessor
|
public class PictureTaskProcessor : ITaskProcessor
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<MyDbContext> _contextFactory;
|
private readonly IDbContextFactory<MyDbContext> _contextFactory;
|
||||||
@@ -60,7 +74,7 @@ namespace Foxel.Services.Background.Processors
|
|||||||
var originalFilePathFromPayload = payload.OriginalFilePath;
|
var originalFilePathFromPayload = payload.OriginalFilePath;
|
||||||
string localFilePath = "";
|
string localFilePath = "";
|
||||||
bool isTempFile = false;
|
bool isTempFile = false;
|
||||||
string thumbnailForAI = string.Empty;
|
// 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);
|
||||||
@@ -70,8 +84,7 @@ namespace Foxel.Services.Background.Processors
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var picture = await dbContext.Pictures.Include(p => p.User).FirstOrDefaultAsync(p => p.Id == pictureId);
|
||||||
var picture = await dbContext.Pictures.Include(p => p.User).ThenInclude(u => u.Tags).FirstOrDefaultAsync(p => p.Id == pictureId);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -83,7 +96,6 @@ namespace Foxel.Services.Background.Processors
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
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;
|
||||||
@@ -94,7 +106,7 @@ namespace Foxel.Services.Background.Processors
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 15, currentBackgroundTaskState: currentBackgroundTaskState);
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 25, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress
|
||||||
localFilePath = await storageService.ExecuteAsync(picture.StorageType,
|
localFilePath = await storageService.ExecuteAsync(picture.StorageType,
|
||||||
provider => provider.DownloadFileAsync(originalFilePathFromPayload));
|
provider => provider.DownloadFileAsync(originalFilePathFromPayload));
|
||||||
isTempFile = true;
|
isTempFile = true;
|
||||||
@@ -105,22 +117,28 @@ namespace Foxel.Services.Background.Processors
|
|||||||
throw new Exception($"找不到图片文件: {localFilePath} (源路径: {originalFilePathFromPayload})");
|
throw new Exception($"找不到图片文件: {localFilePath} (源路径: {originalFilePathFromPayload})");
|
||||||
}
|
}
|
||||||
|
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 20, currentBackgroundTaskState: currentBackgroundTaskState);
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 50, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress
|
||||||
thumbnailForAI = localFilePath;
|
|
||||||
|
|
||||||
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);
|
||||||
var thumbnailDiskPath = Path.Combine(tempThumbContainer, Path.GetFileNameWithoutExtension(Path.GetFileName(localFilePath)) + "_thumb.webp");
|
|
||||||
|
// Derive baseName from OriginalPath (which is in originalFilePathFromPayload)
|
||||||
|
// originalFilePathFromPayload is the stored path/key, not a local path.
|
||||||
|
// We need the base name (UUID part) from the picture's OriginalPath.
|
||||||
|
string baseNameFromOriginalPath = Path.GetFileNameWithoutExtension(picture.OriginalPath);
|
||||||
|
|
||||||
|
var thumbnailDiskPath = Path.Combine(tempThumbContainer, $"{baseNameFromOriginalPath}-thumbnail-temp.webp");
|
||||||
|
|
||||||
await ImageHelper.CreateThumbnailAsync(localFilePath, thumbnailDiskPath, 500);
|
await ImageHelper.CreateThumbnailAsync(localFilePath, thumbnailDiskPath, 500);
|
||||||
thumbnailForAI = thumbnailDiskPath;
|
// thumbnailForAI = thumbnailDiskPath; // This temp path is for AI, but AI is in next step
|
||||||
|
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 25, 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 = Path.GetFileNameWithoutExtension(picture.Path.Split('/').LastOrDefault() ?? picture.Name) + "_thumb.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.StorageType,
|
||||||
@@ -129,104 +147,18 @@ namespace Foxel.Services.Background.Processors
|
|||||||
|
|
||||||
if (Directory.Exists(tempThumbContainer)) Directory.Delete(tempThumbContainer, true);
|
if (Directory.Exists(tempThumbContainer)) Directory.Delete(tempThumbContainer, true);
|
||||||
}
|
}
|
||||||
else
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 80, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress
|
||||||
{
|
|
||||||
if (picture.StorageType != StorageType.Local && !string.IsNullOrEmpty(picture.ThumbnailPath))
|
|
||||||
{
|
|
||||||
thumbnailForAI = await storageService.ExecuteAsync(picture.StorageType,
|
|
||||||
provider => provider.DownloadFileAsync(picture.ThumbnailPath));
|
|
||||||
}
|
|
||||||
else if (!string.IsNullOrEmpty(picture.ThumbnailPath))
|
|
||||||
{
|
|
||||||
// 对于本地存储的缩略图,也基于 ContentRootPath 构建路径
|
|
||||||
thumbnailForAI = Path.Combine(contentRootPath, picture.ThumbnailPath.TrimStart('/'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!File.Exists(thumbnailForAI) && isTempFile) // If thumbnailForAI was meant to be a temp file but doesn't exist, re-download or handle
|
|
||||||
{
|
|
||||||
_logger.LogWarning("AI分析所需的缩略图文件不存在: {ThumbnailPath}", thumbnailForAI);
|
|
||||||
// Fallback or error handling if thumbnailForAI is critical
|
|
||||||
if (string.IsNullOrEmpty(picture.ThumbnailPath) || picture.StorageType == StorageType.Local)
|
|
||||||
{
|
|
||||||
thumbnailForAI = localFilePath; // Fallback to original if thumbnail is missing and was supposed to be local or not generated
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Attempt to re-download if it was from remote storage
|
|
||||||
thumbnailForAI = await storageService.ExecuteAsync(picture.StorageType, provider => provider.DownloadFileAsync(picture.ThumbnailPath!));
|
|
||||||
if (!File.Exists(thumbnailForAI)) throw new Exception($"无法获取用于AI分析的缩略图: {picture.ThumbnailPath}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 30, 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 UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 50, currentBackgroundTaskState: currentBackgroundTaskState);
|
|
||||||
string base64Image = await ImageHelper.ConvertImageToBase64(thumbnailForAI);
|
|
||||||
var (title, description) = await aiService.AnalyzeImageAsync(base64Image);
|
|
||||||
|
|
||||||
string finalTitle = !string.IsNullOrWhiteSpace(title) && title != "AI生成的标题" ? title : Path.GetFileNameWithoutExtension(picture.Name);
|
|
||||||
string finalDescription = !string.IsNullOrWhiteSpace(description) && description != "AI生成的描述" ? description : picture.Description;
|
|
||||||
picture.Name = finalTitle;
|
|
||||||
picture.Description = finalDescription;
|
|
||||||
|
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 60, currentBackgroundTaskState: currentBackgroundTaskState);
|
|
||||||
var combinedText = $"{finalTitle}. {finalDescription}";
|
|
||||||
var embedding = await aiService.GetEmbeddingAsync(combinedText);
|
|
||||||
picture.Embedding = embedding;
|
|
||||||
|
|
||||||
if (picture.UserId.HasValue && embedding != null && embedding.Length > 0)
|
|
||||||
{
|
|
||||||
var vectorDbService = scope.ServiceProvider.GetRequiredService<IVectorDbService>();
|
|
||||||
await vectorDbService.AddPictureToUserCollectionAsync(picture.UserId.Value, new Models.Vector.PictureVector
|
|
||||||
{
|
|
||||||
Id = (ulong)picture.Id,
|
|
||||||
Name = picture.Name,
|
|
||||||
Embedding = embedding
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 70, currentBackgroundTaskState: currentBackgroundTaskState);
|
|
||||||
var availableTagNames = await dbContext.Tags.Select(t => t.Name).ToListAsync();
|
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 80, currentBackgroundTaskState: currentBackgroundTaskState);
|
|
||||||
var matchedTagNames = await aiService.GenerateTagsFromImageAsync(base64Image, availableTagNames, true);
|
|
||||||
|
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 90, currentBackgroundTaskState: currentBackgroundTaskState);
|
|
||||||
if (picture.User != null && matchedTagNames.Any())
|
|
||||||
{
|
|
||||||
picture.Tags ??= new List<Tag>();
|
|
||||||
foreach (var tagName in matchedTagNames)
|
|
||||||
{
|
|
||||||
var existingTag = await dbContext.Tags.FirstOrDefaultAsync(t => t.Name.ToLower() == tagName.ToLower());
|
|
||||||
if (existingTag == null)
|
|
||||||
{
|
|
||||||
existingTag = new Tag { Name = tagName.Trim(), Description = tagName.Trim() };
|
|
||||||
dbContext.Tags.Add(existingTag);
|
|
||||||
}
|
|
||||||
if (!picture.Tags.Any(t => t.Id == existingTag.Id)) picture.Tags.Add(existingTag);
|
|
||||||
|
|
||||||
picture.User.Tags ??= new List<Tag>();
|
|
||||||
if (!picture.User.Tags.Any(t => t.Id == existingTag.Id)) picture.User.Tags.Add(existingTag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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}", currentBackgroundTaskState.Id, pictureId);
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed, currentBackgroundTaskState.Progress, ex.Message, currentBackgroundTaskState: currentBackgroundTaskState);
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed, currentBackgroundTaskState.Progress, ex.Message, currentBackgroundTaskState: currentBackgroundTaskState);
|
||||||
if (picture != null)
|
|
||||||
{
|
|
||||||
// Potentially update picture entity itself if needed, though task failure is primary
|
|
||||||
await dbContext.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -234,11 +166,6 @@ namespace Foxel.Services.Background.Processors
|
|||||||
{
|
{
|
||||||
try { File.Delete(localFilePath); } catch (Exception ex) { _logger.LogWarning(ex, "删除临时主图片文件失败: {FilePath}", localFilePath); }
|
try { File.Delete(localFilePath); } catch (Exception ex) { _logger.LogWarning(ex, "删除临时主图片文件失败: {FilePath}", localFilePath); }
|
||||||
}
|
}
|
||||||
bool thumbnailIsTemp = thumbnailForAI.StartsWith(Path.GetTempPath());
|
|
||||||
if (thumbnailIsTemp && thumbnailForAI != localFilePath && File.Exists(thumbnailForAI))
|
|
||||||
{
|
|
||||||
try { File.Delete(thumbnailForAI); } catch (Exception ex) { _logger.LogWarning(ex, "删除临时缩略图文件失败: {FilePath}", thumbnailForAI); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+107
-125
@@ -343,6 +343,8 @@ public class PictureService(
|
|||||||
Name = picture.Name,
|
Name = picture.Name,
|
||||||
Path = storageService.ExecuteAsync(picture.StorageType, provider =>
|
Path = storageService.ExecuteAsync(picture.StorageType, provider =>
|
||||||
Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))).Result,
|
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) ?
|
ThumbnailPath = !string.IsNullOrEmpty(picture.ThumbnailPath) ?
|
||||||
storageService.ExecuteAsync(picture.StorageType, provider =>
|
storageService.ExecuteAsync(picture.StorageType, provider =>
|
||||||
Task.FromResult(provider.GetUrl(picture.ThumbnailPath))).Result
|
Task.FromResult(provider.GetUrl(picture.ThumbnailPath))).Result
|
||||||
@@ -479,126 +481,95 @@ public class PictureService(
|
|||||||
quality = int.Parse(defaultQualityConfig);
|
quality = int.Parse(defaultQualityConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
string originalFileName = fileName;
|
string baseName = Guid.NewGuid().ToString();
|
||||||
string finalFileName = fileName;
|
string originalFileExtension = Path.GetExtension(fileName);
|
||||||
string finalContentType = contentType;
|
string originalStorageFileName = $"{baseName}{originalFileExtension}";
|
||||||
Stream finalStream = fileStream;
|
|
||||||
|
|
||||||
// 如果需要格式转换
|
string? tempOriginalLocalPath = null;
|
||||||
if (convertToFormat != ImageFormat.Original)
|
string? tempConvertedHdLocalPath = null;
|
||||||
{
|
string? tempThumbnailLocalPath = null;
|
||||||
// 创建临时文件保存原始上传内容
|
|
||||||
string tempOriginalFile = Path.GetTempFileName();
|
|
||||||
string tempConvertedFile = Path.GetTempFileName();
|
|
||||||
|
|
||||||
try
|
string storedOriginalPath;
|
||||||
{
|
string storedHdPath;
|
||||||
// 保存原始文件到临时位置
|
string? storedThumbnailPath = null;
|
||||||
await using (var tempFileStream = new FileStream(tempOriginalFile, FileMode.Create))
|
|
||||||
{
|
|
||||||
await fileStream.CopyToAsync(tempFileStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换格式
|
|
||||||
string convertedFilePath = await ImageHelper.ConvertImageFormatAsync(
|
|
||||||
tempOriginalFile, tempConvertedFile, convertToFormat, quality);
|
|
||||||
|
|
||||||
// 更新文件信息
|
|
||||||
string newExtension = ImageHelper.GetFileExtensionFromFormat(convertToFormat);
|
|
||||||
finalFileName = Path.ChangeExtension(Path.GetFileNameWithoutExtension(originalFileName), newExtension);
|
|
||||||
finalContentType = ImageHelper.GetMimeTypeFromFormat(convertToFormat);
|
|
||||||
|
|
||||||
// 创建新的流用于上传转换后的文件
|
|
||||||
finalStream = new FileStream(convertedFilePath, FileMode.Open, FileAccess.Read);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 清理临时文件
|
|
||||||
if (File.Exists(tempOriginalFile)) File.Delete(tempOriginalFile);
|
|
||||||
if (File.Exists(tempConvertedFile)) File.Delete(tempConvertedFile);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
string? tempOriginalFileForThumbnail = null;
|
|
||||||
string? tempThumbnailFile = null;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 如果是匿名用户或者需要立即生成缩略图,先保存到临时文件
|
tempOriginalLocalPath = Path.GetTempFileName() + originalFileExtension;
|
||||||
bool shouldGenerateThumbnail = userId.HasValue; // 只为注册用户生成缩略图
|
File.Move(Path.GetTempFileName(), tempOriginalLocalPath);
|
||||||
|
await using (var tempFileStream = new FileStream(tempOriginalLocalPath, FileMode.Create))
|
||||||
if (shouldGenerateThumbnail)
|
|
||||||
{
|
{
|
||||||
tempOriginalFileForThumbnail = Path.GetTempFileName();
|
await fileStream.CopyToAsync(tempFileStream);
|
||||||
|
|
||||||
// 保存原始文件到临时位置用于生成缩略图
|
|
||||||
await using (var tempFileStream = new FileStream(tempOriginalFileForThumbnail, FileMode.Create))
|
|
||||||
{
|
|
||||||
if (finalStream != fileStream) // 已经是转换后的流
|
|
||||||
{
|
|
||||||
finalStream.Position = 0;
|
|
||||||
await finalStream.CopyToAsync(tempFileStream);
|
|
||||||
finalStream.Position = 0; // 重置位置用于后续上传
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await finalStream.CopyToAsync(tempFileStream);
|
|
||||||
finalStream.Position = 0; // 重置位置用于后续上传
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用存储服务保存文件
|
await using (var originalLocalStream = new FileStream(tempOriginalLocalPath, FileMode.Open, FileAccess.Read))
|
||||||
string relativePath = await storageService.ExecuteAsync(storageType.Value,
|
{
|
||||||
provider => provider.SaveAsync(finalStream, finalFileName, finalContentType));
|
storedOriginalPath = await storageService.ExecuteAsync(storageType.Value,
|
||||||
|
provider => provider.SaveAsync(originalLocalStream, originalStorageFileName, contentType));
|
||||||
|
}
|
||||||
|
|
||||||
string? thumbnailPath = null;
|
string hdStorageFileName;
|
||||||
|
string hdContentType;
|
||||||
|
string sourceForHdProcessing = tempOriginalLocalPath;
|
||||||
|
|
||||||
// 生成缩略图
|
if (convertToFormat != ImageFormat.Original)
|
||||||
if (shouldGenerateThumbnail && !string.IsNullOrEmpty(tempOriginalFileForThumbnail))
|
{
|
||||||
|
string convertedExtension = ImageHelper.GetFileExtensionFromFormat(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);
|
||||||
|
|
||||||
|
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)
|
||||||
|
bool shouldGenerateThumbnailNow = userId.HasValue;
|
||||||
|
if (shouldGenerateThumbnailNow)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
tempThumbnailFile = Path.GetTempFileName();
|
tempThumbnailLocalPath = Path.GetTempFileName() + ".webp";
|
||||||
string thumbnailFileName = Path.ChangeExtension(tempThumbnailFile, ".webp");
|
File.Move(Path.GetTempFileName(), tempThumbnailLocalPath);
|
||||||
File.Move(tempThumbnailFile, thumbnailFileName);
|
|
||||||
tempThumbnailFile = thumbnailFileName;
|
|
||||||
|
|
||||||
// 生成缩略图
|
await ImageHelper.CreateThumbnailAsync(tempOriginalLocalPath, tempThumbnailLocalPath, 500);
|
||||||
await ImageHelper.CreateThumbnailAsync(tempOriginalFileForThumbnail, tempThumbnailFile, 500);
|
|
||||||
|
|
||||||
// 上传缩略图
|
string thumbnailUploadFileName = $"{baseName}-thumbnail.webp";
|
||||||
await using var thumbnailFileStream = new FileStream(tempThumbnailFile, FileMode.Open, FileAccess.Read);
|
await using var thumbnailFileStream = new FileStream(tempThumbnailLocalPath, FileMode.Open, FileAccess.Read);
|
||||||
var thumbnailStorageFileName = Path.GetFileNameWithoutExtension(finalFileName) + "_thumb.webp";
|
storedThumbnailPath = await storageService.ExecuteAsync(storageType.Value,
|
||||||
|
provider => provider.SaveAsync(thumbnailFileStream, thumbnailUploadFileName, "image/webp"));
|
||||||
thumbnailPath = await storageService.ExecuteAsync(storageType.Value,
|
|
||||||
provider => provider.SaveAsync(thumbnailFileStream, thumbnailStorageFileName, "image/webp"));
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "生成缩略图失败");
|
logger.LogError(ex, "生成和上传缩略图失败 during initial upload");
|
||||||
|
// Continue without thumbnail if it fails here, background task can try later if needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建基本的Picture对象,使用文件名作为标题和描述
|
string initialTitle = Path.GetFileNameWithoutExtension(fileName);
|
||||||
string initialTitle = Path.GetFileNameWithoutExtension(originalFileName);
|
|
||||||
string initialDescription = $"Uploaded on {DateTime.UtcNow}";
|
string initialDescription = $"Uploaded on {DateTime.UtcNow}";
|
||||||
|
|
||||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
await using var dbContext = 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)
|
if (user == null) throw new Exception("找不到指定的用户");
|
||||||
{
|
|
||||||
throw new Exception("找不到指定的用户");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查相册是否存在并且属于当前用户
|
|
||||||
Album? album = null;
|
Album? album = null;
|
||||||
if (albumId.HasValue)
|
if (albumId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -616,38 +587,45 @@ public class PictureService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isAnonymous = userId == null;
|
|
||||||
|
|
||||||
// 创建图片对象并保存到数据库
|
// 创建图片对象并保存到数据库
|
||||||
var picture = new Picture
|
var picture = new Picture
|
||||||
{
|
{
|
||||||
Name = initialTitle,
|
Name = initialTitle,
|
||||||
Description = initialDescription,
|
Description = initialDescription,
|
||||||
Path = relativePath, // 这是存储服务返回的路径/键
|
OriginalPath = storedOriginalPath, // Store path to original uploaded file
|
||||||
|
Path = storedHdPath, // Store path to HD (possibly converted) file
|
||||||
|
ThumbnailPath = storedThumbnailPath, // Store path to thumbnail
|
||||||
User = user,
|
User = user,
|
||||||
Permission = permission,
|
Permission = permission,
|
||||||
AlbumId = albumId,
|
AlbumId = albumId,
|
||||||
StorageType = storageType.Value,
|
StorageType = storageType.Value,
|
||||||
// ProcessingStatus 等字段已移除
|
// ProcessingStatus 等字段已移除
|
||||||
ThumbnailPath = thumbnailPath // 如果生成了缩略图,则保存其存储路径/键
|
|
||||||
};
|
};
|
||||||
|
|
||||||
dbContext.Pictures.Add(picture);
|
dbContext.Pictures.Add(picture);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
if (!isAnonymous)
|
if (userId != null) // Only queue for registered users
|
||||||
{
|
{
|
||||||
// 使用 relativePath (即存储服务中的对象键/路径) 来排队任务
|
// Pass OriginalPath for EXIF extraction and other initial processing
|
||||||
await backgroundTaskQueue.QueuePictureProcessingTaskAsync(picture.Id, picture.Path);
|
await backgroundTaskQueue.QueuePictureProcessingTaskAsync(picture.Id, picture.OriginalPath);
|
||||||
|
|
||||||
|
var visualRecognitionPayload = new Background.Processors.VisualRecognitionPayload
|
||||||
|
{
|
||||||
|
PictureId = picture.Id,
|
||||||
|
UserIdForPicture = picture.UserId
|
||||||
|
};
|
||||||
|
await backgroundTaskQueue.QueueVisualRecognitionTaskAsync(visualRecognitionPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回图片基本信息
|
|
||||||
var pictureResponse = new PictureResponse
|
var pictureResponse = new PictureResponse
|
||||||
{
|
{
|
||||||
Id = picture.Id,
|
Id = picture.Id,
|
||||||
Name = picture.Name,
|
Name = picture.Name,
|
||||||
Path = await storageService.ExecuteAsync(picture.StorageType, provider =>
|
Path = await storageService.ExecuteAsync(picture.StorageType, provider =>
|
||||||
Task.FromResult(provider.GetUrl(picture.Path))), // 使用 picture.Path
|
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)
|
ThumbnailPath = !string.IsNullOrEmpty(picture.ThumbnailPath)
|
||||||
? await storageService.ExecuteAsync(picture.StorageType, provider =>
|
? await storageService.ExecuteAsync(picture.StorageType, provider =>
|
||||||
Task.FromResult(provider.GetUrl(picture.ThumbnailPath)))
|
Task.FromResult(provider.GetUrl(picture.ThumbnailPath)))
|
||||||
@@ -665,27 +643,18 @@ public class PictureService(
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
// 清理临时文件
|
// Clean up temporary local files
|
||||||
if (!string.IsNullOrEmpty(tempOriginalFileForThumbnail) && File.Exists(tempOriginalFileForThumbnail))
|
if (!string.IsNullOrEmpty(tempOriginalLocalPath) && File.Exists(tempOriginalLocalPath))
|
||||||
{
|
{
|
||||||
try { File.Delete(tempOriginalFileForThumbnail); } catch { }
|
try { File.Delete(tempOriginalLocalPath); } catch { /* ignored */ }
|
||||||
}
|
}
|
||||||
|
if (!string.IsNullOrEmpty(tempConvertedHdLocalPath) && File.Exists(tempConvertedHdLocalPath))
|
||||||
if (!string.IsNullOrEmpty(tempThumbnailFile) && File.Exists(tempThumbnailFile))
|
|
||||||
{
|
{
|
||||||
try { File.Delete(tempThumbnailFile); } catch { }
|
try { File.Delete(tempConvertedHdLocalPath); } catch { /* ignored */ }
|
||||||
}
|
}
|
||||||
|
if (!string.IsNullOrEmpty(tempThumbnailLocalPath) && File.Exists(tempThumbnailLocalPath))
|
||||||
// 清理转换后的临时流
|
|
||||||
if (finalStream != fileStream && finalStream is FileStream tempFileStream)
|
|
||||||
{
|
{
|
||||||
string tempFilePath = tempFileStream.Name;
|
try { File.Delete(tempThumbnailLocalPath); } catch { /* ignored */ }
|
||||||
finalStream.Dispose();
|
|
||||||
if (File.Exists(tempFilePath)) File.Delete(tempFilePath);
|
|
||||||
|
|
||||||
// 同时清理原始临时文件
|
|
||||||
string tempOriginalFileFromConvert = Path.ChangeExtension(tempFilePath, null);
|
|
||||||
if (File.Exists(tempOriginalFileFromConvert)) File.Delete(tempOriginalFileFromConvert);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -736,10 +705,10 @@ public class PictureService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var filesToDelete =
|
var filesToDelete =
|
||||||
new List<(int PictureId, string Path, string ThumbnailPath, int? UserId, StorageType StorageType)>();
|
new List<(int PictureId, string Path, string? ThumbnailPath, string OriginalPath, int? UserId, StorageType StorageType)>();
|
||||||
foreach (var picture in picturesToDelete)
|
foreach (var picture in picturesToDelete)
|
||||||
{
|
{
|
||||||
filesToDelete.Add((picture.Id, picture.Path, picture.ThumbnailPath ?? string.Empty, picture.User?.Id,
|
filesToDelete.Add((picture.Id, picture.Path, picture.ThumbnailPath, picture.OriginalPath, picture.User?.Id,
|
||||||
picture.StorageType));
|
picture.StorageType));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,7 +718,7 @@ public class PictureService(
|
|||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var (pictureId, path, thumbnailPath, userId, storageType) in filesToDelete)
|
foreach (var (pictureId, path, thumbnailPath, originalPath, userId, storageType) in filesToDelete)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -757,11 +726,21 @@ public class PictureService(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 使用存储服务删除文件
|
// Delete original file
|
||||||
await storageService.ExecuteAsync(storageType,
|
if (!string.IsNullOrEmpty(originalPath))
|
||||||
provider => provider.DeleteAsync(path));
|
{
|
||||||
|
await storageService.ExecuteAsync(storageType,
|
||||||
|
provider => provider.DeleteAsync(originalPath));
|
||||||
|
}
|
||||||
|
|
||||||
// 删除缩略图
|
// Delete HD/processed file
|
||||||
|
if (!string.IsNullOrEmpty(path) && path != originalPath) // Avoid double delete if Path is same as OriginalPath
|
||||||
|
{
|
||||||
|
await storageService.ExecuteAsync(storageType,
|
||||||
|
provider => provider.DeleteAsync(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete thumbnail
|
||||||
if (!string.IsNullOrEmpty(thumbnailPath))
|
if (!string.IsNullOrEmpty(thumbnailPath))
|
||||||
{
|
{
|
||||||
await storageService.ExecuteAsync(storageType,
|
await storageService.ExecuteAsync(storageType,
|
||||||
@@ -868,8 +847,11 @@ public class PictureService(
|
|||||||
Name = picture.Name,
|
Name = picture.Name,
|
||||||
Path = await storageService.ExecuteAsync(picture.StorageType, provider =>
|
Path = await storageService.ExecuteAsync(picture.StorageType, provider =>
|
||||||
Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))),
|
Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))),
|
||||||
ThumbnailPath = await storageService.ExecuteAsync(picture.StorageType, provider =>
|
OriginalPath = await storageService.ExecuteAsync(picture.StorageType, provider => // Added OriginalPath
|
||||||
Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? string.Empty))),
|
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,
|
Description = picture.Description,
|
||||||
CreatedAt = picture.CreatedAt,
|
CreatedAt = picture.CreatedAt,
|
||||||
Tags = picture.Tags?.Select(t => t.Name).ToList() ?? new List<string>(),
|
Tags = picture.Tags?.Select(t => t.Name).ToList() ?? new List<string>(),
|
||||||
|
|||||||
@@ -68,8 +68,10 @@ public class CosStorageProvider(IConfigService configService, ILogger<CosStorage
|
|||||||
{
|
{
|
||||||
// 创建唯一的文件存储路径
|
// 创建唯一的文件存储路径
|
||||||
string currentDate = DateTime.Now.ToString("yyyy/MM");
|
string currentDate = DateTime.Now.ToString("yyyy/MM");
|
||||||
string ext = Path.GetExtension(fileName);
|
// fileName 参数现在是期望的最终文件名部分,例如 "guid.ext"
|
||||||
string objectKey = $"{currentDate}/{Guid.NewGuid()}{ext}";
|
// string ext = Path.GetExtension(fileName); // 旧的 fileName 是原始文件名,现在 fileName 是目标文件名
|
||||||
|
// string objectKey = $"{currentDate}/{Guid.NewGuid()}{ext}"; // 旧逻辑
|
||||||
|
string objectKey = $"{currentDate}/{fileName}"; // 新逻辑
|
||||||
|
|
||||||
// 创建临时文件
|
// 创建临时文件
|
||||||
string tempPath = Path.GetTempFileName();
|
string tempPath = Path.GetTempFileName();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace Foxel.Services.Storage.Providers;
|
namespace Foxel.Services.Storage.Providers;
|
||||||
|
|
||||||
[StorageProvider(StorageType.Local)]
|
[StorageProvider(StorageType.Local)]
|
||||||
public class LocalStorageProvider(IConfigService configService, ILogger<LocalStorageProvider> logger) : IStorageProvider
|
public class LocalStorageProvider(IConfigService configService) : IStorageProvider
|
||||||
{
|
{
|
||||||
private readonly string _baseDirectory = Path.Combine(Directory.GetCurrentDirectory(), "Uploads");
|
private readonly string _baseDirectory = Path.Combine(Directory.GetCurrentDirectory(), "Uploads");
|
||||||
|
|
||||||
@@ -15,8 +15,7 @@ public class LocalStorageProvider(IConfigService configService, ILogger<LocalSto
|
|||||||
string folder = Path.Combine(_baseDirectory, currentDate);
|
string folder = Path.Combine(_baseDirectory, currentDate);
|
||||||
Directory.CreateDirectory(folder);
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
string ext = Path.GetExtension(fileName);
|
string newFileName = fileName;
|
||||||
string newFileName = $"{Guid.NewGuid()}{ext}";
|
|
||||||
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);
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
|
|||||||
{
|
{
|
||||||
// 创建唯一的文件存储路径
|
// 创建唯一的文件存储路径
|
||||||
string currentDate = DateTime.Now.ToString("yyyy/MM");
|
string currentDate = DateTime.Now.ToString("yyyy/MM");
|
||||||
string ext = Path.GetExtension(fileName);
|
// fileName 参数现在是期望的最终文件名部分,例如 "guid.ext"
|
||||||
string objectKey = $"{currentDate}/{Guid.NewGuid()}{ext}";
|
// string ext = Path.GetExtension(fileName); // 旧的 fileName 是原始文件名,现在 fileName 是目标文件名
|
||||||
|
// string objectKey = $"{currentDate}/{Guid.NewGuid()}{ext}"; // 旧逻辑
|
||||||
|
string objectKey = $"{currentDate}/{fileName}"; // 新逻辑
|
||||||
|
|
||||||
using var client = CreateClient();
|
using var client = CreateClient();
|
||||||
using var transferUtility = new TransferUtility(client);
|
using var transferUtility = new TransferUtility(client);
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
|
|||||||
|
|
||||||
// 创建唯一的文件存储路径
|
// 创建唯一的文件存储路径
|
||||||
string currentDate = DateTime.Now.ToString("yyyy/MM");
|
string currentDate = DateTime.Now.ToString("yyyy/MM");
|
||||||
string ext = Path.GetExtension(fileName);
|
// fileName 参数现在是期望的最终文件名部分,例如 "guid.ext"
|
||||||
string newFileName = $"{Guid.NewGuid()}{ext}";
|
// string ext = Path.GetExtension(fileName); // 旧的 fileName 是原始文件名,现在 fileName 是目标文件名
|
||||||
|
// string newFileName = $"{Guid.NewGuid()}{ext}"; // 旧逻辑
|
||||||
|
string newFileName = fileName; // 新逻辑
|
||||||
string relativePath = $"{basePath}/{currentDate}/{newFileName}";
|
string relativePath = $"{basePath}/{currentDate}/{newFileName}";
|
||||||
|
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const { Title, Text } = Typography;
|
|||||||
// 定义任务类型映射
|
// 定义任务类型映射
|
||||||
const taskTypeDisplayMapping: { [key: number]: string } = {
|
const taskTypeDisplayMapping: { [key: number]: string } = {
|
||||||
0: '图片处理',
|
0: '图片处理',
|
||||||
|
1: '视觉识别', // 新增视觉识别任务类型
|
||||||
};
|
};
|
||||||
|
|
||||||
const BackgroundTasks: React.FC = () => {
|
const BackgroundTasks: React.FC = () => {
|
||||||
@@ -126,7 +127,7 @@ const BackgroundTasks: React.FC = () => {
|
|||||||
dataIndex: 'taskName', // Changed dataIndex
|
dataIndex: 'taskName', // Changed dataIndex
|
||||||
key: 'taskName',
|
key: 'taskName',
|
||||||
render: (text: string, record: TaskDetailsViewModel) => ( // Updated type and logic
|
render: (text: string, record: TaskDetailsViewModel) => ( // Updated type and logic
|
||||||
record.taskType === 0 && record.relatedEntityId // 修正: 使用数字 0 比较
|
(record.taskType === 0 || record.taskType === 1) && record.relatedEntityId // 检查是否为图片处理或视觉识别任务
|
||||||
? <Link to={`/pictures/${record.relatedEntityId}`}>{text}</Link>
|
? <Link to={`/pictures/${record.relatedEntityId}`}>{text}</Link>
|
||||||
: text
|
: text
|
||||||
),
|
),
|
||||||
@@ -151,6 +152,11 @@ const BackgroundTasks: React.FC = () => {
|
|||||||
key: 'taskType',
|
key: 'taskType',
|
||||||
render: (taskType: number | undefined) => // 接收数字类型的 taskType
|
render: (taskType: number | undefined) => // 接收数字类型的 taskType
|
||||||
taskType !== undefined ? taskTypeDisplayMapping[taskType] || `未知类型 (${taskType})` : '-',
|
taskType !== undefined ? taskTypeDisplayMapping[taskType] || `未知类型 (${taskType})` : '-',
|
||||||
|
filters: [ // 可以为任务类型添加筛选器
|
||||||
|
{ text: '图片处理', value: 0 },
|
||||||
|
{ text: '视觉识别', value: 1 },
|
||||||
|
],
|
||||||
|
onFilter: (value, record: TaskDetailsViewModel) => record.taskType === (value as number),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '进度',
|
title: '进度',
|
||||||
|
|||||||
@@ -157,18 +157,19 @@ const routes: RouteConfig[] = [
|
|||||||
hideInMenu: true,
|
hideInMenu: true,
|
||||||
breadcrumb: {
|
breadcrumb: {
|
||||||
title: '用户详情',
|
title: '用户详情',
|
||||||
parent: 'admin-user' // 修改: 指向父路由的 key
|
parent: 'admin-user'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'pictures',
|
path: 'pictures',
|
||||||
key: 'admin-picture',
|
key: 'admin-picture',
|
||||||
icon: <PictureOutlined />,
|
icon: <PictureOutlined />,
|
||||||
label: '图片管理',
|
label: '图片',
|
||||||
element: <PictureManagement />,
|
element: <PictureManagement />,
|
||||||
area: 'admin',
|
area: 'admin',
|
||||||
|
groupLabel: '资源管理',
|
||||||
breadcrumb: {
|
breadcrumb: {
|
||||||
title: '图片管理'
|
title: '图片'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-1
@@ -7,7 +7,9 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"System.Net.Http.HttpClient": "Warning",
|
"System.Net.Http.HttpClient": "Warning",
|
||||||
"Microsoft.AspNetCore": "Warning",
|
"Microsoft.AspNetCore": "Warning",
|
||||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
|
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
|
||||||
|
"Foxel.Services.Background.Processors.PictureTaskProcessor": "Warning",
|
||||||
|
"Foxel.Services.Background.Processors.VisualRecognitionTaskProcessor": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": ""
|
"AllowedHosts": ""
|
||||||
|
|||||||
Reference in New Issue
Block a user