feat: 新增可配置的文件库扫描日志记录及缩略图清理功能
- 为文件库的定时轮询及根目录扫描过程,新增扫描生命周期日志 - 允许通过 `appsettings` 配置文件,自定义定时扫描的轮询间隔 - 当检测到媒体文件已被删除时,自动清理过期的缩略图映射记录及对应的缩略图文件
This commit is contained in:
parent
6ef410fdfa
commit
27e4029f4a
1
.gitignore
vendored
1
.gitignore
vendored
@ -33,3 +33,4 @@
|
|||||||
/obj
|
/obj
|
||||||
/.claude
|
/.claude
|
||||||
/.codex-build
|
/.codex-build
|
||||||
|
/FileShare-API/Properties
|
||||||
|
|||||||
@ -52,6 +52,7 @@ namespace FileShare_API.Configuration
|
|||||||
services.AddScoped<IFileLibraryEndpointService, FileLibraryEndpointService>();
|
services.AddScoped<IFileLibraryEndpointService, FileLibraryEndpointService>();
|
||||||
services.AddScoped<IFileStreamService, FileStreamService>();
|
services.AddScoped<IFileStreamService, FileStreamService>();
|
||||||
services.AddScoped<IQrCodeService, QrCodeService>();
|
services.AddScoped<IQrCodeService, QrCodeService>();
|
||||||
|
services.Configure<FileLibraryScanOptions>(configuration.GetSection(nameof(FileLibraryScanOptions)));
|
||||||
services.AddHostedService<FileLibraryScanHostedService>();
|
services.AddHostedService<FileLibraryScanHostedService>();
|
||||||
services.AddDataProtection()
|
services.AddDataProtection()
|
||||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "data-protection-keys")));
|
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "data-protection-keys")));
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ActiveDebugProfile>http</ActiveDebugProfile>
|
<ActiveDebugProfile>http</ActiveDebugProfile>
|
||||||
|
<NameOfLastUsedPublishProfile>D:\Project\FileShare\FileShare-API\Properties\PublishProfiles\FolderProfile.pubxml</NameOfLastUsedPublishProfile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor>
|
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor>
|
||||||
|
|||||||
@ -1,27 +1,30 @@
|
|||||||
using FileShare_Services.Services.FileLibrary;
|
using FileShare_Services.Services.FileLibrary;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace FileShare_API.Services
|
namespace FileShare_API.Services
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 文件库定时扫描后台服务,每分钟执行一次扫描,检查是否有需要扫描的文件库目录。
|
/// 文件库定时扫描后台服务,按配置间隔检查是否有需要扫描的文件库目录。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FileLibraryScanHostedService(IServiceScopeFactory scopeFactory, ILogger<FileLibraryScanHostedService> logger)
|
public sealed class FileLibraryScanHostedService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IOptions<FileLibraryScanOptions> options,
|
||||||
|
ILogger<FileLibraryScanHostedService> logger)
|
||||||
: BackgroundService
|
: BackgroundService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>后台服务检查到期扫描任务的轮询间隔。</summary>
|
||||||
/// 扫描间隔,固定为 1 分钟。
|
private readonly TimeSpan _interval = TimeSpan.FromMinutes(Math.Max(1, options.Value.PollingIntervalMinutes));
|
||||||
/// </summary>
|
|
||||||
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 启动扫描循环,首次立即执行,之后按 <see cref="Interval"/> 周期重复执行。
|
/// 启动扫描循环,首次立即执行,之后按配置的轮询间隔重复执行。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="stoppingToken">应用关闭时触发的取消令牌。</param>
|
/// <param name="stoppingToken">应用关闭时触发的取消令牌。</param>
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
|
logger.LogInformation("文件库定时扫描服务已启动,轮询间隔 {IntervalMinutes} 分钟。", _interval.TotalMinutes);
|
||||||
await ScanAsync(stoppingToken);
|
await ScanAsync(stoppingToken);
|
||||||
|
|
||||||
using var timer = new PeriodicTimer(Interval);
|
using var timer = new PeriodicTimer(_interval);
|
||||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||||
{
|
{
|
||||||
await ScanAsync(stoppingToken);
|
await ScanAsync(stoppingToken);
|
||||||
@ -34,14 +37,21 @@ namespace FileShare_API.Services
|
|||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
private async Task ScanAsync(CancellationToken cancellationToken)
|
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var startedAt = DateTime.UtcNow;
|
||||||
|
logger.LogInformation("文件库定时扫描轮询开始。");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var scope = scopeFactory.CreateAsyncScope();
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
var scanner = scope.ServiceProvider.GetRequiredService<IFileLibraryService>();
|
var scanner = scope.ServiceProvider.GetRequiredService<IFileLibraryService>();
|
||||||
await scanner.ScanDueRootsAsync(cancellationToken);
|
await scanner.ScanDueRootsAsync(cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"文件库定时扫描轮询完成,耗时 {ElapsedMilliseconds} ms。",
|
||||||
|
(DateTime.UtcNow - startedAt).TotalMilliseconds);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
|
logger.LogInformation("文件库定时扫描轮询已取消。");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
13
FileShare-API/Services/FileLibraryScanOptions.cs
Normal file
13
FileShare-API/Services/FileLibraryScanOptions.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
namespace FileShare_API.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 文件库定时扫描后台服务配置。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FileLibraryScanOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 后台服务检查到期文件库目录的轮询间隔分钟数。
|
||||||
|
/// </summary>
|
||||||
|
public int PollingIntervalMinutes { get; set; } = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,5 +29,8 @@
|
|||||||
"RootPath": "thumbnails",
|
"RootPath": "thumbnails",
|
||||||
"FfmpegPath": "tools/ffmpeg/bin/ffmpeg.exe",
|
"FfmpegPath": "tools/ffmpeg/bin/ffmpeg.exe",
|
||||||
"FfprobePath": "tools/ffmpeg/bin/ffprobe.exe"
|
"FfprobePath": "tools/ffmpeg/bin/ffprobe.exe"
|
||||||
|
},
|
||||||
|
"FileLibraryScanOptions": {
|
||||||
|
"PollingIntervalMinutes": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -129,10 +129,14 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == rootId, cancellationToken)
|
var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == rootId, cancellationToken)
|
||||||
?? throw new InvalidOperationException("文件库目录不存在。");
|
?? throw new InvalidOperationException("文件库目录不存在。");
|
||||||
|
|
||||||
|
Serilog.Log.Information("开始扫描文件库根目录 RootId={RootId} Path={Path}", root.Id, root.Path);
|
||||||
root.LastScanStartedAt = DateTime.UtcNow;
|
root.LastScanStartedAt = DateTime.UtcNow;
|
||||||
root.LastScanError = null;
|
root.LastScanError = null;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var staleFileCount = 0;
|
||||||
|
var staleThumbnailPaths = new List<string>();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(root.Path))
|
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;
|
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;
|
root.LastScanCompletedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
DeleteThumbnailFiles(staleThumbnailPaths);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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);
|
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);
|
return ToRootDto(root, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,19 +274,29 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
var roots = await db.ManagedLibraryRoots
|
var roots = await db.ManagedLibraryRoots
|
||||||
.Where(root => root.IsEnabled && root.IsAvailable)
|
.Where(root => root.IsEnabled && root.IsAvailable)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
var dueRoots = roots
|
||||||
foreach (var root in roots)
|
.Where(root =>
|
||||||
{
|
|
||||||
var interval = Math.Max(1, root.ScanIntervalMinutes);
|
|
||||||
var isDue = root.LastScanCompletedAt is null || root.LastScanCompletedAt.Value.AddMinutes(interval) <= now;
|
|
||||||
if (!isDue)
|
|
||||||
{
|
{
|
||||||
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
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -498,6 +553,29 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
return string.IsNullOrWhiteSpace(directory.Name) ? directory.FullName : directory.Name;
|
return string.IsNullOrWhiteSpace(directory.Name) ? directory.FullName : directory.Name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除数据库中已释放的缩略图文件,失败时保留扫描结果并记录告警。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="relativePaths">待删除缩略图相对路径。</param>
|
||||||
|
private void DeleteThumbnailFiles(IEnumerable<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 规范化扫描间隔,限制在 1 到 1440 分钟范围内,未指定时使用默认值。
|
/// 规范化扫描间隔,限制在 1 到 1440 分钟范围内,未指定时使用默认值。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user