mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-07 05:32:49 +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<IStorageService, StorageService>();
|
||||
services.AddSingleton<PictureTaskProcessor>();
|
||||
services.AddSingleton<VisualRecognitionTaskProcessor>();
|
||||
services.AddSingleton<IDatabaseInitializer, DatabaseInitializer>();
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Uploads\" />
|
||||
<Folder Include="Uploads\2025\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Foxel.Models.DataBase
|
||||
public enum TaskType
|
||||
{
|
||||
PictureProcessing = 0,
|
||||
VisualRecognition = 1,
|
||||
}
|
||||
|
||||
public enum TaskExecutionStatus
|
||||
|
||||
@@ -9,8 +9,16 @@ public class Picture : BaseModel
|
||||
{
|
||||
[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;
|
||||
|
||||
/// <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(2000)] public string Description { get; set; } = string.Empty;
|
||||
|
||||
@@ -8,6 +8,7 @@ public record PictureResponse
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Path { get; set; }
|
||||
public string? ThumbnailPath { get; set; }
|
||||
public string? OriginalPath { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public List<string>? Tags { get; set; }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Foxel.Models.DataBase;
|
||||
@@ -25,19 +24,17 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
||||
IConfigService configuration,
|
||||
ILogger<BackgroundTaskQueue> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider; // Keep IServiceProvider to resolve processors
|
||||
_serviceProvider = serviceProvider;
|
||||
_contextFactory = contextFactory;
|
||||
_logger = logger;
|
||||
_processingTasks = new List<Task>();
|
||||
_maxConcurrentTasks = configuration.GetValueAsync("BackgroundTasks:MaxConcurrentTasks", 10).Result; // 保持原有逻辑
|
||||
_signal = new SemaphoreSlim(_maxConcurrentTasks);
|
||||
var options = new BoundedChannelOptions(10000) // 保持原有逻辑
|
||||
var options = new BoundedChannelOptions(10000)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.Wait
|
||||
};
|
||||
_queue = Channel.CreateBounded<Guid>(options);
|
||||
|
||||
// 启动处理器,确保在服务启动时就开始处理队列
|
||||
StartProcessor();
|
||||
}
|
||||
|
||||
@@ -51,7 +48,7 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
||||
throw new KeyNotFoundException($"找不到 PictureId: {pictureId} 的图片");
|
||||
}
|
||||
|
||||
var payload = new PictureProcessingPayload
|
||||
var payload = new Processors.PictureProcessingPayload
|
||||
{
|
||||
PictureId = pictureId,
|
||||
OriginalFilePath = originalFilePath,
|
||||
@@ -74,7 +71,39 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
||||
await _queue.Writer.WriteAsync(backgroundTask.Id);
|
||||
_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;
|
||||
}
|
||||
@@ -96,13 +125,29 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
||||
var picture = await dbContext.Pictures.FindAsync(task.RelatedEntityId.Value);
|
||||
if (picture != null)
|
||||
{
|
||||
taskName = picture.Name;
|
||||
taskName = $"图片处理: {picture.Name}";
|
||||
}
|
||||
else
|
||||
{
|
||||
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
|
||||
{
|
||||
@@ -156,30 +201,28 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
||||
{
|
||||
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||
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))
|
||||
.ToListAsync();
|
||||
|
||||
if (unfinishedTasks.Any())
|
||||
{
|
||||
_logger.LogInformation("正在恢复 {Count} 个未完成的图片处理任务", unfinishedTasks.Count);
|
||||
_logger.LogInformation("正在恢复 {Count} 个未完成的任务", unfinishedTasks.Count);
|
||||
foreach (var task in unfinishedTasks)
|
||||
{
|
||||
// 确保任务状态在数据库中被重置为 Pending,以防上次运行时停在 Processing 状态
|
||||
if (task.Status == TaskExecutionStatus.Processing)
|
||||
{
|
||||
task.Status = TaskExecutionStatus.Pending;
|
||||
task.StartedAt = null; // 重置开始时间
|
||||
// 保留 Progress 和 ErrorMessage 以供参考
|
||||
task.StartedAt = null;
|
||||
}
|
||||
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
|
||||
{
|
||||
_logger.LogInformation("没有需要恢复的图片处理任务");
|
||||
_logger.LogInformation("没有需要恢复的任务");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -243,7 +286,9 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
||||
case TaskType.PictureProcessing:
|
||||
processor = scope.ServiceProvider.GetRequiredService<PictureTaskProcessor>();
|
||||
break;
|
||||
// Future task types can be added here
|
||||
case TaskType.VisualRecognition: // Added case for VisualRecognition
|
||||
processor = scope.ServiceProvider.GetRequiredService<VisualRecognitionTaskProcessor>();
|
||||
break;
|
||||
default:
|
||||
_logger.LogError("未找到任务类型 {TaskType} 的处理器: TaskId={TaskId}", taskToCheck.Type, taskToCheck.Id);
|
||||
await MarkTaskAsFailedByQueue(taskToCheck.Id, $"未找到任务类型 {taskToCheck.Type} 的处理器。");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Foxel.Models.DataBase;
|
||||
using Foxel.Services.Background.Processors; // For VisualRecognitionPayload
|
||||
|
||||
namespace Foxel.Services.Background;
|
||||
|
||||
@@ -8,13 +9,20 @@ namespace Foxel.Services.Background;
|
||||
public interface IBackgroundTaskQueue
|
||||
{
|
||||
/// <summary>
|
||||
/// 将图片处理任务添加到队列
|
||||
/// 将图片处理任务(元数据和缩略图)添加到队列
|
||||
/// </summary>
|
||||
/// <param name="pictureId">图片ID</param>
|
||||
/// <param name="originalFilePath">原始图片路径</param>
|
||||
/// <returns>任务ID</returns>
|
||||
Task<Guid> QueuePictureProcessingTaskAsync(int pictureId, string originalFilePath);
|
||||
|
||||
/// <summary>
|
||||
/// 将视觉识别任务添加到队列
|
||||
/// </summary>
|
||||
/// <param name="payload">视觉识别任务的Payload</param>
|
||||
/// <returns>任务ID</returns>
|
||||
Task<Guid> QueueVisualRecognitionTaskAsync(VisualRecognitionPayload payload);
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户的所有任务状态 (目前主要指图片处理任务)
|
||||
/// </summary>
|
||||
|
||||
@@ -6,9 +6,23 @@ using Foxel.Utils;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using Foxel.Services.Attributes;
|
||||
using Foxel.Services.Background; // Added for IBackgroundTaskQueue
|
||||
|
||||
namespace Foxel.Services.Background.Processors
|
||||
{
|
||||
public class PictureProcessingPayload // Ensure this is defined or imported
|
||||
{
|
||||
public 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
|
||||
{
|
||||
private readonly IDbContextFactory<MyDbContext> _contextFactory;
|
||||
@@ -60,18 +74,17 @@ namespace Foxel.Services.Background.Processors
|
||||
var originalFilePathFromPayload = payload.OriginalFilePath;
|
||||
string localFilePath = "";
|
||||
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();
|
||||
var currentBackgroundTaskState = await dbContext.BackgroundTasks.FindAsync(backgroundTask.Id);
|
||||
if (currentBackgroundTaskState == null)
|
||||
{
|
||||
_logger.LogError("在 PictureTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id);
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var picture = await dbContext.Pictures.Include(p => p.User).ThenInclude(u => u.Tags).FirstOrDefaultAsync(p => p.Id == pictureId);
|
||||
var picture = await dbContext.Pictures.Include(p => p.User).FirstOrDefaultAsync(p => p.Id == pictureId);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -83,10 +96,9 @@ namespace Foxel.Services.Background.Processors
|
||||
}
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var aiService = scope.ServiceProvider.GetRequiredService<IAiService>();
|
||||
var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>();
|
||||
|
||||
string contentRootPath = _environment.ContentRootPath;
|
||||
string contentRootPath = _environment.ContentRootPath;
|
||||
|
||||
if (picture.StorageType == StorageType.Local)
|
||||
{
|
||||
@@ -94,7 +106,7 @@ namespace Foxel.Services.Background.Processors
|
||||
}
|
||||
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,
|
||||
provider => provider.DownloadFileAsync(originalFilePathFromPayload));
|
||||
isTempFile = true;
|
||||
@@ -105,22 +117,28 @@ namespace Foxel.Services.Background.Processors
|
||||
throw new Exception($"找不到图片文件: {localFilePath} (源路径: {originalFilePathFromPayload})");
|
||||
}
|
||||
|
||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 20, currentBackgroundTaskState: currentBackgroundTaskState);
|
||||
thumbnailForAI = localFilePath;
|
||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 50, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress
|
||||
|
||||
if (string.IsNullOrEmpty(picture.ThumbnailPath))
|
||||
{
|
||||
var tempThumbContainer = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
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);
|
||||
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);
|
||||
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(
|
||||
picture.StorageType,
|
||||
@@ -129,104 +147,18 @@ namespace Foxel.Services.Background.Processors
|
||||
|
||||
if (Directory.Exists(tempThumbContainer)) Directory.Delete(tempThumbContainer, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
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);
|
||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 80, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress
|
||||
var exifInfo = await ImageHelper.ExtractExifInfoAsync(localFilePath);
|
||||
picture.ExifInfo = exifInfo;
|
||||
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 UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Completed, 100, completedAt: DateTime.UtcNow, currentBackgroundTaskState: currentBackgroundTaskState);
|
||||
|
||||
}
|
||||
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);
|
||||
if (picture != null)
|
||||
{
|
||||
// Potentially update picture entity itself if needed, though task failure is primary
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -234,11 +166,6 @@ namespace Foxel.Services.Background.Processors
|
||||
{
|
||||
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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
218
Services/Background/Processors/VisualRecognitionTaskProcessor.cs
Normal file
218
Services/Background/Processors/VisualRecognitionTaskProcessor.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
using Foxel.Models.DataBase;
|
||||
using Foxel.Services.AI;
|
||||
using Foxel.Services.Storage;
|
||||
using Foxel.Services.VectorDB;
|
||||
using Foxel.Utils;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using Foxel.Services.Attributes;
|
||||
|
||||
namespace Foxel.Services.Background.Processors
|
||||
{
|
||||
|
||||
public class VisualRecognitionTaskProcessor : 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)
|
||||
{
|
||||
if (backgroundTask.Payload == null)
|
||||
{
|
||||
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "任务 Payload 为空。");
|
||||
_logger.LogError("视觉识别任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
VisualRecognitionPayload? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<VisualRecognitionPayload>(backgroundTask.Payload);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "无法解析视觉识别任务的 Payload: TaskId={TaskId}", backgroundTask.Id);
|
||||
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 解析失败。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload == null || payload.PictureId == 0)
|
||||
{
|
||||
_logger.LogError("视觉识别任务的 Payload 无效或缺少 PictureId: TaskId={TaskId}", backgroundTask.Id);
|
||||
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 无效或缺少 PictureId。");
|
||||
return;
|
||||
}
|
||||
|
||||
var pictureId = payload.PictureId;
|
||||
string thumbnailForAIDownloadPath = string.Empty; // Path if thumbnail needs to be downloaded
|
||||
bool isTempThumbnailFile = false;
|
||||
|
||||
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||
var currentBackgroundTaskState = await dbContext.BackgroundTasks.FindAsync(backgroundTask.Id);
|
||||
if (currentBackgroundTaskState == null)
|
||||
{
|
||||
_logger.LogError("在 VisualRecognitionTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var picture = await dbContext.Pictures.Include(p => p.User).ThenInclude(u => u.Tags).FirstOrDefaultAsync(p => p.Id == pictureId);
|
||||
|
||||
try
|
||||
{
|
||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 10, currentBackgroundTaskState: currentBackgroundTaskState);
|
||||
|
||||
if (picture == null)
|
||||
{
|
||||
throw new Exception($"找不到ID为{pictureId}的图片。");
|
||||
}
|
||||
if (string.IsNullOrEmpty(picture.ThumbnailPath))
|
||||
{
|
||||
throw new Exception($"图片ID {pictureId} 的缩略图路径为空,无法进行AI分析。");
|
||||
}
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var aiService = scope.ServiceProvider.GetRequiredService<IAiService>();
|
||||
var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>();
|
||||
string contentRootPath = _environment.ContentRootPath;
|
||||
string actualThumbnailPathForAI;
|
||||
|
||||
if (picture.StorageType == StorageType.Local)
|
||||
{
|
||||
actualThumbnailPathForAI = Path.Combine(contentRootPath, picture.ThumbnailPath.TrimStart('/'));
|
||||
}
|
||||
else // Remote storage
|
||||
{
|
||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 15, currentBackgroundTaskState: currentBackgroundTaskState);
|
||||
thumbnailForAIDownloadPath = await storageService.ExecuteAsync(picture.StorageType,
|
||||
provider => provider.DownloadFileAsync(picture.ThumbnailPath));
|
||||
actualThumbnailPathForAI = thumbnailForAIDownloadPath;
|
||||
isTempThumbnailFile = true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(actualThumbnailPathForAI) || !File.Exists(actualThumbnailPathForAI))
|
||||
{
|
||||
throw new Exception($"找不到用于AI分析的缩略图文件: {actualThumbnailPathForAI} (源路径: {picture.ThumbnailPath})");
|
||||
}
|
||||
|
||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 20, currentBackgroundTaskState: currentBackgroundTaskState);
|
||||
string base64Image = await ImageHelper.ConvertImageToBase64(actualThumbnailPathForAI);
|
||||
|
||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 40, currentBackgroundTaskState: currentBackgroundTaskState);
|
||||
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; // Potentially overwrites name set from filename
|
||||
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(); // Save all AI-related changes to Picture
|
||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Completed, 100, completedAt: DateTime.UtcNow, currentBackgroundTaskState: currentBackgroundTaskState);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "视觉识别任务失败: TaskId={TaskId}, PictureId={PictureId}", currentBackgroundTaskState.Id, pictureId);
|
||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed, currentBackgroundTaskState.Progress, ex.Message, currentBackgroundTaskState: currentBackgroundTaskState);
|
||||
// dbContext.SaveChangesAsync() might be called in UpdateTaskStatusInDb or here if picture state needs saving on error
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (isTempThumbnailFile && File.Exists(thumbnailForAIDownloadPath))
|
||||
{
|
||||
try { File.Delete(thumbnailForAIDownloadPath); } catch (Exception ex) { _logger.LogWarning(ex, "删除临时AI缩略图文件失败: {FilePath}", thumbnailForAIDownloadPath); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
var taskToUpdate = currentBackgroundTaskState ?? await dbContext.BackgroundTasks.FindAsync(taskId);
|
||||
|
||||
if (taskToUpdate != null)
|
||||
{
|
||||
if (currentBackgroundTaskState != null && dbContext.Entry(currentBackgroundTaskState).State == EntityState.Detached)
|
||||
{
|
||||
dbContext.BackgroundTasks.Attach(currentBackgroundTaskState);
|
||||
}
|
||||
|
||||
taskToUpdate.Status = status;
|
||||
taskToUpdate.Progress = progress;
|
||||
taskToUpdate.ErrorMessage = string.IsNullOrEmpty(error) ? taskToUpdate.ErrorMessage : error; // Keep existing error if new one is null/empty
|
||||
if (startedAt.HasValue) taskToUpdate.StartedAt = startedAt;
|
||||
if (completedAt.HasValue) taskToUpdate.CompletedAt = completedAt;
|
||||
|
||||
if ((status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) && !taskToUpdate.StartedAt.HasValue)
|
||||
{
|
||||
taskToUpdate.StartedAt = taskToUpdate.CreatedAt; // Ensure StartedAt is set
|
||||
}
|
||||
if (status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed)
|
||||
{
|
||||
taskToUpdate.CompletedAt ??= DateTime.UtcNow; // Ensure CompletedAt is set
|
||||
}
|
||||
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
_logger.LogInformation("任务状态更新 (VisualRecognitionProcessor): TaskId={TaskId}, Status={Status}, Progress={Progress}%", taskId, status, progress);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("尝试在 VisualRecognitionProcessor 中更新不存在的任务状态: TaskId={TaskId}", taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,6 +343,8 @@ public class PictureService(
|
||||
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
|
||||
@@ -479,126 +481,95 @@ public class PictureService(
|
||||
quality = int.Parse(defaultQualityConfig);
|
||||
}
|
||||
|
||||
string originalFileName = fileName;
|
||||
string finalFileName = fileName;
|
||||
string finalContentType = contentType;
|
||||
Stream finalStream = fileStream;
|
||||
string baseName = Guid.NewGuid().ToString();
|
||||
string originalFileExtension = Path.GetExtension(fileName);
|
||||
string originalStorageFileName = $"{baseName}{originalFileExtension}";
|
||||
|
||||
// 如果需要格式转换
|
||||
if (convertToFormat != ImageFormat.Original)
|
||||
{
|
||||
// 创建临时文件保存原始上传内容
|
||||
string tempOriginalFile = Path.GetTempFileName();
|
||||
string tempConvertedFile = Path.GetTempFileName();
|
||||
|
||||
try
|
||||
{
|
||||
// 保存原始文件到临时位置
|
||||
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;
|
||||
string? tempOriginalLocalPath = null;
|
||||
string? tempConvertedHdLocalPath = null;
|
||||
string? tempThumbnailLocalPath = null;
|
||||
|
||||
string storedOriginalPath;
|
||||
string storedHdPath;
|
||||
string? storedThumbnailPath = null;
|
||||
|
||||
try
|
||||
{
|
||||
// 如果是匿名用户或者需要立即生成缩略图,先保存到临时文件
|
||||
bool shouldGenerateThumbnail = userId.HasValue; // 只为注册用户生成缩略图
|
||||
|
||||
if (shouldGenerateThumbnail)
|
||||
tempOriginalLocalPath = Path.GetTempFileName() + originalFileExtension;
|
||||
File.Move(Path.GetTempFileName(), tempOriginalLocalPath);
|
||||
await using (var tempFileStream = new FileStream(tempOriginalLocalPath, FileMode.Create))
|
||||
{
|
||||
tempOriginalFileForThumbnail = Path.GetTempFileName();
|
||||
|
||||
// 保存原始文件到临时位置用于生成缩略图
|
||||
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 fileStream.CopyToAsync(tempFileStream);
|
||||
}
|
||||
|
||||
// 使用存储服务保存文件
|
||||
string relativePath = await storageService.ExecuteAsync(storageType.Value,
|
||||
provider => provider.SaveAsync(finalStream, finalFileName, finalContentType));
|
||||
await using (var originalLocalStream = new FileStream(tempOriginalLocalPath, FileMode.Open, FileAccess.Read))
|
||||
{
|
||||
storedOriginalPath = await storageService.ExecuteAsync(storageType.Value,
|
||||
provider => provider.SaveAsync(originalLocalStream, originalStorageFileName, contentType));
|
||||
}
|
||||
|
||||
string? thumbnailPath = null;
|
||||
string hdStorageFileName;
|
||||
string hdContentType;
|
||||
string sourceForHdProcessing = tempOriginalLocalPath;
|
||||
|
||||
// 生成缩略图
|
||||
if (shouldGenerateThumbnail && !string.IsNullOrEmpty(tempOriginalFileForThumbnail))
|
||||
if (convertToFormat != ImageFormat.Original)
|
||||
{
|
||||
string convertedExtension = ImageHelper.GetFileExtensionFromFormat(convertToFormat);
|
||||
hdStorageFileName = $"{baseName}-high-definition{convertedExtension}";
|
||||
hdContentType = ImageHelper.GetMimeTypeFromFormat(convertToFormat);
|
||||
|
||||
tempConvertedHdLocalPath = Path.GetTempFileName() + convertedExtension;
|
||||
File.Move(Path.GetTempFileName(), tempConvertedHdLocalPath);
|
||||
|
||||
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
|
||||
{
|
||||
tempThumbnailFile = Path.GetTempFileName();
|
||||
string thumbnailFileName = Path.ChangeExtension(tempThumbnailFile, ".webp");
|
||||
File.Move(tempThumbnailFile, thumbnailFileName);
|
||||
tempThumbnailFile = thumbnailFileName;
|
||||
tempThumbnailLocalPath = Path.GetTempFileName() + ".webp";
|
||||
File.Move(Path.GetTempFileName(), tempThumbnailLocalPath);
|
||||
|
||||
// 生成缩略图
|
||||
await ImageHelper.CreateThumbnailAsync(tempOriginalFileForThumbnail, tempThumbnailFile, 500);
|
||||
await ImageHelper.CreateThumbnailAsync(tempOriginalLocalPath, tempThumbnailLocalPath, 500);
|
||||
|
||||
// 上传缩略图
|
||||
await using var thumbnailFileStream = new FileStream(tempThumbnailFile, FileMode.Open, FileAccess.Read);
|
||||
var thumbnailStorageFileName = Path.GetFileNameWithoutExtension(finalFileName) + "_thumb.webp";
|
||||
|
||||
thumbnailPath = await storageService.ExecuteAsync(storageType.Value,
|
||||
provider => provider.SaveAsync(thumbnailFileStream, thumbnailStorageFileName, "image/webp"));
|
||||
string thumbnailUploadFileName = $"{baseName}-thumbnail.webp";
|
||||
await using var thumbnailFileStream = new FileStream(tempThumbnailLocalPath, FileMode.Open, FileAccess.Read);
|
||||
storedThumbnailPath = await storageService.ExecuteAsync(storageType.Value,
|
||||
provider => provider.SaveAsync(thumbnailFileStream, thumbnailUploadFileName, "image/webp"));
|
||||
}
|
||||
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(originalFileName);
|
||||
|
||||
string initialTitle = Path.GetFileNameWithoutExtension(fileName);
|
||||
string initialDescription = $"Uploaded on {DateTime.UtcNow}";
|
||||
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
// 获取用户
|
||||
User? user = null;
|
||||
if (userId is not null)
|
||||
{
|
||||
user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (user == null)
|
||||
{
|
||||
throw new Exception("找不到指定的用户");
|
||||
}
|
||||
if (user == null) throw new Exception("找不到指定的用户");
|
||||
}
|
||||
|
||||
// 检查相册是否存在并且属于当前用户
|
||||
Album? album = null;
|
||||
if (albumId.HasValue)
|
||||
{
|
||||
@@ -616,38 +587,45 @@ public class PictureService(
|
||||
}
|
||||
}
|
||||
|
||||
bool isAnonymous = userId == null;
|
||||
|
||||
// 创建图片对象并保存到数据库
|
||||
var picture = new Picture
|
||||
{
|
||||
Name = initialTitle,
|
||||
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,
|
||||
Permission = permission,
|
||||
AlbumId = albumId,
|
||||
StorageType = storageType.Value,
|
||||
// ProcessingStatus 等字段已移除
|
||||
ThumbnailPath = thumbnailPath // 如果生成了缩略图,则保存其存储路径/键
|
||||
};
|
||||
|
||||
dbContext.Pictures.Add(picture);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
if (!isAnonymous)
|
||||
if (userId != null) // Only queue for registered users
|
||||
{
|
||||
// 使用 relativePath (即存储服务中的对象键/路径) 来排队任务
|
||||
await backgroundTaskQueue.QueuePictureProcessingTaskAsync(picture.Id, picture.Path);
|
||||
// Pass OriginalPath for EXIF extraction and other initial processing
|
||||
await backgroundTaskQueue.QueuePictureProcessingTaskAsync(picture.Id, picture.OriginalPath);
|
||||
|
||||
var visualRecognitionPayload = new Background.Processors.VisualRecognitionPayload
|
||||
{
|
||||
PictureId = picture.Id,
|
||||
UserIdForPicture = picture.UserId
|
||||
};
|
||||
await backgroundTaskQueue.QueueVisualRecognitionTaskAsync(visualRecognitionPayload);
|
||||
}
|
||||
|
||||
// 返回图片基本信息
|
||||
var pictureResponse = new PictureResponse
|
||||
{
|
||||
Id = picture.Id,
|
||||
Name = picture.Name,
|
||||
Path = await storageService.ExecuteAsync(picture.StorageType, provider =>
|
||||
Task.FromResult(provider.GetUrl(picture.Path))), // 使用 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)
|
||||
? await storageService.ExecuteAsync(picture.StorageType, provider =>
|
||||
Task.FromResult(provider.GetUrl(picture.ThumbnailPath)))
|
||||
@@ -665,27 +643,18 @@ public class PictureService(
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 清理临时文件
|
||||
if (!string.IsNullOrEmpty(tempOriginalFileForThumbnail) && File.Exists(tempOriginalFileForThumbnail))
|
||||
// Clean up temporary local files
|
||||
if (!string.IsNullOrEmpty(tempOriginalLocalPath) && File.Exists(tempOriginalLocalPath))
|
||||
{
|
||||
try { File.Delete(tempOriginalFileForThumbnail); } catch { }
|
||||
try { File.Delete(tempOriginalLocalPath); } catch { /* ignored */ }
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(tempThumbnailFile) && File.Exists(tempThumbnailFile))
|
||||
if (!string.IsNullOrEmpty(tempConvertedHdLocalPath) && File.Exists(tempConvertedHdLocalPath))
|
||||
{
|
||||
try { File.Delete(tempThumbnailFile); } catch { }
|
||||
try { File.Delete(tempConvertedHdLocalPath); } catch { /* ignored */ }
|
||||
}
|
||||
|
||||
// 清理转换后的临时流
|
||||
if (finalStream != fileStream && finalStream is FileStream tempFileStream)
|
||||
if (!string.IsNullOrEmpty(tempThumbnailLocalPath) && File.Exists(tempThumbnailLocalPath))
|
||||
{
|
||||
string tempFilePath = tempFileStream.Name;
|
||||
finalStream.Dispose();
|
||||
if (File.Exists(tempFilePath)) File.Delete(tempFilePath);
|
||||
|
||||
// 同时清理原始临时文件
|
||||
string tempOriginalFileFromConvert = Path.ChangeExtension(tempFilePath, null);
|
||||
if (File.Exists(tempOriginalFileFromConvert)) File.Delete(tempOriginalFileFromConvert);
|
||||
try { File.Delete(tempThumbnailLocalPath); } catch { /* ignored */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -736,10 +705,10 @@ public class PictureService(
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -749,7 +718,7 @@ public class PictureService(
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
foreach (var (pictureId, path, thumbnailPath, userId, storageType) in filesToDelete)
|
||||
foreach (var (pictureId, path, thumbnailPath, originalPath, userId, storageType) in filesToDelete)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -757,11 +726,21 @@ public class PictureService(
|
||||
|
||||
try
|
||||
{
|
||||
// 使用存储服务删除文件
|
||||
await storageService.ExecuteAsync(storageType,
|
||||
provider => provider.DeleteAsync(path));
|
||||
// Delete original file
|
||||
if (!string.IsNullOrEmpty(originalPath))
|
||||
{
|
||||
await storageService.ExecuteAsync(storageType,
|
||||
provider => provider.DeleteAsync(originalPath));
|
||||
}
|
||||
|
||||
// Delete HD/processed file
|
||||
if (!string.IsNullOrEmpty(path) && path != originalPath) // Avoid double delete if Path is same as OriginalPath
|
||||
{
|
||||
await storageService.ExecuteAsync(storageType,
|
||||
provider => provider.DeleteAsync(path));
|
||||
}
|
||||
|
||||
// 删除缩略图
|
||||
// Delete thumbnail
|
||||
if (!string.IsNullOrEmpty(thumbnailPath))
|
||||
{
|
||||
await storageService.ExecuteAsync(storageType,
|
||||
@@ -868,8 +847,11 @@ public class PictureService(
|
||||
Name = picture.Name,
|
||||
Path = await storageService.ExecuteAsync(picture.StorageType, provider =>
|
||||
Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))),
|
||||
ThumbnailPath = await storageService.ExecuteAsync(picture.StorageType, provider =>
|
||||
Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? 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>(),
|
||||
|
||||
@@ -68,8 +68,10 @@ public class CosStorageProvider(IConfigService configService, ILogger<CosStorage
|
||||
{
|
||||
// 创建唯一的文件存储路径
|
||||
string currentDate = DateTime.Now.ToString("yyyy/MM");
|
||||
string ext = Path.GetExtension(fileName);
|
||||
string objectKey = $"{currentDate}/{Guid.NewGuid()}{ext}";
|
||||
// fileName 参数现在是期望的最终文件名部分,例如 "guid.ext"
|
||||
// string ext = Path.GetExtension(fileName); // 旧的 fileName 是原始文件名,现在 fileName 是目标文件名
|
||||
// string objectKey = $"{currentDate}/{Guid.NewGuid()}{ext}"; // 旧逻辑
|
||||
string objectKey = $"{currentDate}/{fileName}"; // 新逻辑
|
||||
|
||||
// 创建临时文件
|
||||
string tempPath = Path.GetTempFileName();
|
||||
|
||||
@@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Foxel.Services.Storage.Providers;
|
||||
|
||||
[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");
|
||||
|
||||
@@ -15,13 +15,12 @@ public class LocalStorageProvider(IConfigService configService, ILogger<LocalSto
|
||||
string folder = Path.Combine(_baseDirectory, currentDate);
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
string ext = Path.GetExtension(fileName);
|
||||
string newFileName = $"{Guid.NewGuid()}{ext}";
|
||||
string newFileName = fileName;
|
||||
string filePath = Path.Combine(folder, newFileName);
|
||||
|
||||
await using var output = new FileStream(filePath, FileMode.Create);
|
||||
await fileStream.CopyToAsync(output);
|
||||
return $"/Uploads/{currentDate}/{newFileName}";
|
||||
return $"/Uploads/{currentDate}/{newFileName}";
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string storagePath)
|
||||
|
||||
@@ -43,8 +43,10 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
|
||||
{
|
||||
// 创建唯一的文件存储路径
|
||||
string currentDate = DateTime.Now.ToString("yyyy/MM");
|
||||
string ext = Path.GetExtension(fileName);
|
||||
string objectKey = $"{currentDate}/{Guid.NewGuid()}{ext}";
|
||||
// fileName 参数现在是期望的最终文件名部分,例如 "guid.ext"
|
||||
// string ext = Path.GetExtension(fileName); // 旧的 fileName 是原始文件名,现在 fileName 是目标文件名
|
||||
// string objectKey = $"{currentDate}/{Guid.NewGuid()}{ext}"; // 旧逻辑
|
||||
string objectKey = $"{currentDate}/{fileName}"; // 新逻辑
|
||||
|
||||
using var client = CreateClient();
|
||||
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 ext = Path.GetExtension(fileName);
|
||||
string newFileName = $"{Guid.NewGuid()}{ext}";
|
||||
// fileName 参数现在是期望的最终文件名部分,例如 "guid.ext"
|
||||
// string ext = Path.GetExtension(fileName); // 旧的 fileName 是原始文件名,现在 fileName 是目标文件名
|
||||
// string newFileName = $"{Guid.NewGuid()}{ext}"; // 旧逻辑
|
||||
string newFileName = fileName; // 新逻辑
|
||||
string relativePath = $"{basePath}/{currentDate}/{newFileName}";
|
||||
|
||||
// 确保目录存在
|
||||
|
||||
@@ -13,6 +13,7 @@ const { Title, Text } = Typography;
|
||||
// 定义任务类型映射
|
||||
const taskTypeDisplayMapping: { [key: number]: string } = {
|
||||
0: '图片处理',
|
||||
1: '视觉识别', // 新增视觉识别任务类型
|
||||
};
|
||||
|
||||
const BackgroundTasks: React.FC = () => {
|
||||
@@ -126,7 +127,7 @@ const BackgroundTasks: React.FC = () => {
|
||||
dataIndex: 'taskName', // Changed dataIndex
|
||||
key: 'taskName',
|
||||
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>
|
||||
: text
|
||||
),
|
||||
@@ -151,6 +152,11 @@ const BackgroundTasks: React.FC = () => {
|
||||
key: 'taskType',
|
||||
render: (taskType: number | undefined) => // 接收数字类型的 taskType
|
||||
taskType !== undefined ? taskTypeDisplayMapping[taskType] || `未知类型 (${taskType})` : '-',
|
||||
filters: [ // 可以为任务类型添加筛选器
|
||||
{ text: '图片处理', value: 0 },
|
||||
{ text: '视觉识别', value: 1 },
|
||||
],
|
||||
onFilter: (value, record: TaskDetailsViewModel) => record.taskType === (value as number),
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
|
||||
@@ -157,19 +157,20 @@ const routes: RouteConfig[] = [
|
||||
hideInMenu: true,
|
||||
breadcrumb: {
|
||||
title: '用户详情',
|
||||
parent: 'admin-user' // 修改: 指向父路由的 key
|
||||
parent: 'admin-user'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'pictures',
|
||||
key: 'admin-picture',
|
||||
icon: <PictureOutlined />,
|
||||
label: '图片管理',
|
||||
label: '图片',
|
||||
element: <PictureManagement />,
|
||||
area: 'admin',
|
||||
groupLabel: '资源管理',
|
||||
breadcrumb: {
|
||||
title: '图片管理'
|
||||
}
|
||||
title: '图片'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'log',
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"Default": "Information",
|
||||
"System.Net.Http.HttpClient": "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": ""
|
||||
|
||||
Reference in New Issue
Block a user