diff --git a/.gitignore b/.gitignore index f222ac6..af6f730 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ /obj /.claude /.codex-build +/FileShare-API/Properties diff --git a/FileShare-API/Configuration/ServicesConfiguration.cs b/FileShare-API/Configuration/ServicesConfiguration.cs index a8eebc0..7284309 100644 --- a/FileShare-API/Configuration/ServicesConfiguration.cs +++ b/FileShare-API/Configuration/ServicesConfiguration.cs @@ -52,6 +52,7 @@ namespace FileShare_API.Configuration services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.Configure(configuration.GetSection(nameof(FileLibraryScanOptions))); services.AddHostedService(); services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "data-protection-keys"))); diff --git a/FileShare-API/FileShare-API.csproj.user b/FileShare-API/FileShare-API.csproj.user index 983ecfc..2a72fce 100644 --- a/FileShare-API/FileShare-API.csproj.user +++ b/FileShare-API/FileShare-API.csproj.user @@ -2,6 +2,7 @@ http + D:\Project\FileShare\FileShare-API\Properties\PublishProfiles\FolderProfile.pubxml ProjectDebugger diff --git a/FileShare-API/Services/FileLibraryScanHostedService.cs b/FileShare-API/Services/FileLibraryScanHostedService.cs index 2e5b9f0..c2eaa59 100644 --- a/FileShare-API/Services/FileLibraryScanHostedService.cs +++ b/FileShare-API/Services/FileLibraryScanHostedService.cs @@ -1,27 +1,30 @@ using FileShare_Services.Services.FileLibrary; +using Microsoft.Extensions.Options; namespace FileShare_API.Services { /// - /// 文件库定时扫描后台服务,每分钟执行一次扫描,检查是否有需要扫描的文件库目录。 + /// 文件库定时扫描后台服务,按配置间隔检查是否有需要扫描的文件库目录。 /// - public sealed class FileLibraryScanHostedService(IServiceScopeFactory scopeFactory, ILogger logger) + public sealed class FileLibraryScanHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) : BackgroundService { - /// - /// 扫描间隔,固定为 1 分钟。 - /// - private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1); + /// 后台服务检查到期扫描任务的轮询间隔。 + private readonly TimeSpan _interval = TimeSpan.FromMinutes(Math.Max(1, options.Value.PollingIntervalMinutes)); /// - /// 启动扫描循环,首次立即执行,之后按 周期重复执行。 + /// 启动扫描循环,首次立即执行,之后按配置的轮询间隔重复执行。 /// /// 应用关闭时触发的取消令牌。 protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + logger.LogInformation("文件库定时扫描服务已启动,轮询间隔 {IntervalMinutes} 分钟。", _interval.TotalMinutes); await ScanAsync(stoppingToken); - using var timer = new PeriodicTimer(Interval); + using var timer = new PeriodicTimer(_interval); while (await timer.WaitForNextTickAsync(stoppingToken)) { await ScanAsync(stoppingToken); @@ -34,14 +37,21 @@ namespace FileShare_API.Services /// 取消令牌。 private async Task ScanAsync(CancellationToken cancellationToken) { + var startedAt = DateTime.UtcNow; + logger.LogInformation("文件库定时扫描轮询开始。"); + try { await using var scope = scopeFactory.CreateAsyncScope(); var scanner = scope.ServiceProvider.GetRequiredService(); await scanner.ScanDueRootsAsync(cancellationToken); + logger.LogInformation( + "文件库定时扫描轮询完成,耗时 {ElapsedMilliseconds} ms。", + (DateTime.UtcNow - startedAt).TotalMilliseconds); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + logger.LogInformation("文件库定时扫描轮询已取消。"); } catch (Exception ex) { diff --git a/FileShare-API/Services/FileLibraryScanOptions.cs b/FileShare-API/Services/FileLibraryScanOptions.cs new file mode 100644 index 0000000..27572a5 --- /dev/null +++ b/FileShare-API/Services/FileLibraryScanOptions.cs @@ -0,0 +1,13 @@ +namespace FileShare_API.Services +{ + /// + /// 文件库定时扫描后台服务配置。 + /// + public sealed class FileLibraryScanOptions + { + /// + /// 后台服务检查到期文件库目录的轮询间隔分钟数。 + /// + public int PollingIntervalMinutes { get; set; } = 1; + } +} diff --git a/FileShare-API/appsettings.json b/FileShare-API/appsettings.json index e7b840c..bfdaec0 100644 --- a/FileShare-API/appsettings.json +++ b/FileShare-API/appsettings.json @@ -29,5 +29,8 @@ "RootPath": "thumbnails", "FfmpegPath": "tools/ffmpeg/bin/ffmpeg.exe", "FfprobePath": "tools/ffmpeg/bin/ffprobe.exe" + }, + "FileLibraryScanOptions": { + "PollingIntervalMinutes": 5 } } diff --git a/FileShare-Services/Services/FileLibrary/FileLibraryService.cs b/FileShare-Services/Services/FileLibrary/FileLibraryService.cs index a54687c..ec9b7f9 100644 --- a/FileShare-Services/Services/FileLibrary/FileLibraryService.cs +++ b/FileShare-Services/Services/FileLibrary/FileLibraryService.cs @@ -129,10 +129,14 @@ namespace FileShare_Services.Services.FileLibrary var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == rootId, cancellationToken) ?? throw new InvalidOperationException("文件库目录不存在。"); + Serilog.Log.Information("开始扫描文件库根目录 RootId={RootId} Path={Path}", root.Id, root.Path); root.LastScanStartedAt = DateTime.UtcNow; root.LastScanError = null; await db.SaveChangesAsync(cancellationToken); + var staleFileCount = 0; + var staleThumbnailPaths = new List(); + try { if (!Directory.Exists(root.Path)) @@ -200,13 +204,48 @@ namespace FileShare_Services.Services.FileLibrary } } - foreach (var stale in existing.Values.Where(file => !seen.Contains(file.AbsolutePath))) + var staleFiles = existing.Values + .Where(file => !seen.Contains(file.AbsolutePath)) + .ToList(); + staleFileCount = staleFiles.Count; + + foreach (var stale in staleFiles) { stale.Exists = false; } + var activeThumbnailIds = existing.Values + .Where(file => file.Exists && file.ThumbnailId is not null) + .Select(file => file.ThumbnailId!.Value) + .ToHashSet(); + var staleThumbnailIds = staleFiles + .Where(file => file.ThumbnailId is not null && !activeThumbnailIds.Contains(file.ThumbnailId.Value)) + .Select(file => file.ThumbnailId!.Value) + .Distinct() + .ToList(); + + if (staleThumbnailIds.Count > 0) + { + var staleThumbnails = await db.ManagedThumbnailMaps + .Where(thumbnail => thumbnail.LibraryRootId == root.Id && staleThumbnailIds.Contains(thumbnail.Id)) + .ToListAsync(cancellationToken); + + staleThumbnailPaths = staleThumbnails + .Select(thumbnail => thumbnail.RelativePath) + .ToList(); + + foreach (var stale in staleFiles.Where(file => file.ThumbnailId is not null && staleThumbnailIds.Contains(file.ThumbnailId.Value))) + { + stale.ThumbnailId = null; + stale.Thumbnail = null; + } + + db.ManagedThumbnailMaps.RemoveRange(staleThumbnails); + } + root.LastScanCompletedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); + DeleteThumbnailFiles(staleThumbnailPaths); } catch (Exception ex) { @@ -219,6 +258,12 @@ namespace FileShare_Services.Services.FileLibrary } var count = await db.ManagedFileRecords.CountAsync(file => file.LibraryRootId == root.Id && file.Exists, cancellationToken); + Serilog.Log.Information( + "扫描文件库根目录完成 RootId={RootId} FileCount={FileCount} StaleFiles={StaleFiles} RemovedThumbnails={RemovedThumbnails}", + root.Id, + count, + staleFileCount, + staleThumbnailPaths.Count); return ToRootDto(root, count); } @@ -229,19 +274,29 @@ namespace FileShare_Services.Services.FileLibrary var roots = await db.ManagedLibraryRoots .Where(root => root.IsEnabled && root.IsAvailable) .ToListAsync(cancellationToken); - - foreach (var root in roots) - { - var interval = Math.Max(1, root.ScanIntervalMinutes); - var isDue = root.LastScanCompletedAt is null || root.LastScanCompletedAt.Value.AddMinutes(interval) <= now; - if (!isDue) + var dueRoots = roots + .Where(root => { - continue; - } + var interval = Math.Max(1, root.ScanIntervalMinutes); + return root.LastScanCompletedAt is null || root.LastScanCompletedAt.Value.AddMinutes(interval) <= now; + }) + .ToList(); + Serilog.Log.Information( + "文件库定时扫描检查完成 CheckedRoots={CheckedRoots} DueRoots={DueRoots}", + roots.Count, + dueRoots.Count); + + foreach (var root in dueRoots) + { try { - await ScanRootAsync(root.Id, cancellationToken); + Serilog.Log.Information("文件库定时扫描开始 RootId={RootId} Path={Path}", root.Id, root.Path); + var scannedRoot = await ScanRootAsync(root.Id, cancellationToken); + Serilog.Log.Information( + "文件库定时扫描完成 RootId={RootId} FileCount={FileCount}", + scannedRoot.Id, + scannedRoot.FileCount); } catch (Exception ex) { @@ -498,6 +553,29 @@ namespace FileShare_Services.Services.FileLibrary return string.IsNullOrWhiteSpace(directory.Name) ? directory.FullName : directory.Name; } + /// + /// 删除数据库中已释放的缩略图文件,失败时保留扫描结果并记录告警。 + /// + /// 待删除缩略图相对路径。 + private void DeleteThumbnailFiles(IEnumerable relativePaths) + { + foreach (var relativePath in relativePaths) + { + try + { + var absolutePath = thumbnailService.GetAbsolutePath(relativePath); + if (File.Exists(absolutePath)) + { + File.Delete(absolutePath); + } + } + catch (Exception ex) + { + Serilog.Log.Warning(ex, "清理失效缩略图文件失败 ThumbnailPath={ThumbnailPath}", relativePath); + } + } + } + /// /// 规范化扫描间隔,限制在 1 到 1440 分钟范围内,未指定时使用默认值。 ///