using dy.net.model.dto; using dy.net.model.entity; using dy.net.storage; using SqlSugar; using MediaStorageType = dy.net.model.dto.StorageType; namespace dy.net.service { public sealed class OpenListDirectoryRepairWorker : BackgroundService { private const int InspectionBatchSize = 4; private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; public OpenListDirectoryRepairWorker( IServiceScopeFactory scopeFactory, ILogger logger) { _scopeFactory = scopeFactory; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await RecoverInterruptedAsync(); while (!stoppingToken.IsCancellationRequested) { try { if (!await RunOneAsync(stoppingToken)) await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { } catch (Exception ex) { _logger.LogError(ex, "OpenList 目录修复后台服务异常"); await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); } } } private async Task RecoverInterruptedAsync() { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var now = DateTime.Now; await db.Updateable() .SetColumns(x => new OpenListDirectoryRepairItem { Status = OpenListDirectoryRepairItemStatus.Pending, ErrorMessage = "应用重启,目录将在恢复后重新检查。", UpdatedAt = now }) .Where(x => x.Status == OpenListDirectoryRepairItemStatus.Inspecting).ExecuteCommandAsync(); await db.Updateable() .SetColumns(x => new OpenListDirectoryRepairItem { Status = OpenListDirectoryRepairItemStatus.EmptyConfirmed, ErrorMessage = "应用在删除确认后重启,需再次确认清理。", UpdatedAt = now }) .Where(x => x.Status == OpenListDirectoryRepairItemStatus.Deleting).ExecuteCommandAsync(); var interrupted = await db.Queryable().Where(x => x.Status == OpenListDirectoryRepairStatus.Scanning || x.Status == OpenListDirectoryRepairStatus.Cleaning).ToListAsync(); foreach (var task in interrupted) { task.Status = task.Status == OpenListDirectoryRepairStatus.Cleaning ? OpenListDirectoryRepairStatus.AwaitingConfirmation : OpenListDirectoryRepairStatus.Queued; task.CurrentDirectory = null; task.ErrorMessage = task.Status == OpenListDirectoryRepairStatus.AwaitingConfirmation ? "应用重启后已停止删除,请重新确认。" : "应用重启后已恢复扫描。"; task.UpdatedAt = now; await db.Updateable(task).ExecuteCommandAsync(); await scope.ServiceProvider.GetRequiredService() .RefreshCountsAsync(task.Id); } } private async Task RunOneAsync(CancellationToken cancellationToken) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var service = scope.ServiceProvider.GetRequiredService(); var task = await db.Queryable().Where(x => x.Status == OpenListDirectoryRepairStatus.Queued || x.Status == OpenListDirectoryRepairStatus.Scanning || x.Status == OpenListDirectoryRepairStatus.Cleaning) .OrderBy(x => x.CreatedAt).FirstAsync(); if (task == null) return false; try { await service.EnsureFingerprintAsync(task); } catch (InvalidOperationException ex) { task.Status = OpenListDirectoryRepairStatus.Paused; task.ErrorMessage = ex.Message; task.CurrentDirectory = null; task.UpdatedAt = DateTime.Now; await db.Updateable(task).ExecuteCommandAsync(); return true; } if (task.Status == OpenListDirectoryRepairStatus.Queued) { task.Status = OpenListDirectoryRepairStatus.Scanning; task.StartedAt ??= DateTime.Now; task.CompletedAt = null; task.UpdatedAt = DateTime.Now; await db.Updateable(task).ExecuteCommandAsync(); } if (task.Status == OpenListDirectoryRepairStatus.Scanning) return await InspectOneAsync(scope.ServiceProvider, task, cancellationToken); return await DeleteOneAsync(scope.ServiceProvider, task, cancellationToken); } private static async Task InspectOneAsync( IServiceProvider services, OpenListDirectoryRepairTask task, CancellationToken cancellationToken) { var db = services.GetRequiredService(); var service = services.GetRequiredService(); var client = services.GetRequiredService(); var items = await db.Queryable().Where(x => x.TaskId == task.Id && x.Status == OpenListDirectoryRepairItemStatus.Pending) .OrderBy(x => x.CreatedAt).Take(InspectionBatchSize).ToListAsync(); if (items.Count == 0) { await CompleteScanAsync(services, task.Id); return true; } var startedAt = DateTime.Now; foreach (var item in items) { item.Status = OpenListDirectoryRepairItemStatus.Inspecting; item.Attempts++; item.ErrorMessage = null; item.UpdatedAt = startedAt; } task.CurrentDirectory = items[0].ActualPath; task.UpdatedAt = startedAt; await db.Updateable(items).ExecuteCommandAsync(); await db.Updateable(task).ExecuteCommandAsync(); var (settings, password) = await service.GetConnectionAsync(); await Task.WhenAll(items.Select(async item => { try { var inspection = await client.InspectKnownDirectoryAsync( settings, password, item.ActualPath, true, cancellationToken); item.EntryCount = inspection.EntryCount; item.Status = !inspection.Exists ? OpenListDirectoryRepairItemStatus.Missing : inspection.EntryCount == 0 ? OpenListDirectoryRepairItemStatus.EmptyConfirmed : OpenListDirectoryRepairItemStatus.SkippedNonEmpty; item.ErrorMessage = inspection.Exists && inspection.EntryCount > 0 ? $"目录包含 {inspection.EntryCount} 个对象,已安全跳过。" : null; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { item.Status = OpenListDirectoryRepairItemStatus.Failed; item.ErrorMessage = ex.GetBaseException().Message; } item.CompletedAt = DateTime.Now; item.UpdatedAt = item.CompletedAt.Value; })); await db.Updateable(items).ExecuteCommandAsync(); await service.RefreshCountsAsync(task.Id); return true; } private static async Task CompleteScanAsync(IServiceProvider services, string taskId) { var db = services.GetRequiredService(); var service = services.GetRequiredService(); await service.RefreshCountsAsync(taskId); var task = await db.Queryable().InSingleAsync(taskId); if (task?.Status != OpenListDirectoryRepairStatus.Scanning) return; task.Status = task.EmptyCount > 0 ? OpenListDirectoryRepairStatus.AwaitingConfirmation : task.FailedCount > 0 ? OpenListDirectoryRepairStatus.PartiallyFailed : OpenListDirectoryRepairStatus.Completed; task.CurrentDirectory = null; task.ScanCompletedAt = DateTime.Now; task.CompletedAt = task.Status is OpenListDirectoryRepairStatus.Completed or OpenListDirectoryRepairStatus.PartiallyFailed ? DateTime.Now : null; task.ErrorMessage = task.FailedCount > 0 ? $"{task.FailedCount} 个目录检查失败,未执行删除。" : null; task.UpdatedAt = DateTime.Now; await db.Updateable(task).ExecuteCommandAsync(); } private static async Task DeleteOneAsync( IServiceProvider services, OpenListDirectoryRepairTask task, CancellationToken cancellationToken) { var db = services.GetRequiredService(); var service = services.GetRequiredService(); var client = services.GetRequiredService(); var item = await db.Queryable().Where(x => x.TaskId == task.Id && x.Status == OpenListDirectoryRepairItemStatus.EmptyConfirmed) .OrderBy(x => x.CreatedAt).FirstAsync(); if (item == null) { await CompleteCleanupAsync(services, task.Id, cancellationToken); return true; } item.Status = OpenListDirectoryRepairItemStatus.Deleting; item.Attempts++; item.ErrorMessage = null; item.UpdatedAt = DateTime.Now; task.CurrentDirectory = item.ActualPath; task.UpdatedAt = item.UpdatedAt; await db.Updateable(item).ExecuteCommandAsync(); await db.Updateable(task).ExecuteCommandAsync(); try { var (settings, password) = await service.GetConnectionAsync(); var before = await client.InspectKnownDirectoryAsync(settings, password, item.ActualPath, true, cancellationToken); if (!before.Exists) { item.Status = OpenListDirectoryRepairItemStatus.Missing; } else if (before.EntryCount > 0) { item.EntryCount = before.EntryCount; item.Status = OpenListDirectoryRepairItemStatus.SkippedNonEmpty; item.ErrorMessage = $"删除前发现目录已有 {before.EntryCount} 个对象,已安全跳过。"; } else { await client.DeleteKnownObjectAsync(settings, password, item.ActualPath, cancellationToken); var after = await client.InspectKnownDirectoryAsync(settings, password, item.ActualPath, true, cancellationToken); if (after.Exists) throw new IOException("OpenList 返回删除成功,但目录仍然存在。"); item.Status = OpenListDirectoryRepairItemStatus.Deleted; } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { item.Status = OpenListDirectoryRepairItemStatus.Failed; item.ErrorMessage = ex.GetBaseException().Message; } item.CompletedAt = DateTime.Now; item.UpdatedAt = item.CompletedAt.Value; await db.Updateable(item).ExecuteCommandAsync(); await service.RefreshCountsAsync(task.Id); return true; } private static async Task CompleteCleanupAsync( IServiceProvider services, string taskId, CancellationToken cancellationToken) { var db = services.GetRequiredService(); var service = services.GetRequiredService(); await service.RefreshCountsAsync(taskId); var task = await db.Queryable().InSingleAsync(taskId); if (task?.Status != OpenListDirectoryRepairStatus.Cleaning) return; task.Status = task.FailedCount > 0 ? OpenListDirectoryRepairStatus.PartiallyFailed : OpenListDirectoryRepairStatus.Completed; task.CurrentDirectory = null; task.CompletedAt = DateTime.Now; task.ErrorMessage = task.FailedCount > 0 ? $"{task.FailedCount} 个目录清理失败,可重试。" : null; task.UpdatedAt = DateTime.Now; await db.Updateable(task).ExecuteCommandAsync(); await RecoverAffectedTasksAsync(services, task, cancellationToken); } private static async Task RecoverAffectedTasksAsync( IServiceProvider services, OpenListDirectoryRepairTask repair, CancellationToken cancellationToken) { var db = services.GetRequiredService(); var videos = await db.Queryable().Where(x => x.StorageType == MediaStorageType.OpenList && x.VideoSavePath.StartsWith(repair.RequestedPath + "/")).ToListAsync(); var storage = services.GetRequiredService(); foreach (var video in videos) { cancellationToken.ThrowIfCancellationRequested(); var canonicalMain = ReplacePrefix(video.VideoSavePath, repair.RequestedPath, repair.CanonicalPath); var length = await storage.GetLengthAsync(canonicalMain, cancellationToken); if (!length.HasValue || length <= 0 || video.FileSize > 0 && length.Value != video.FileSize) continue; video.VideoSavePath = canonicalMain; video.VideoCoverSavePath = ReplacePrefix(video.VideoCoverSavePath, repair.RequestedPath, repair.CanonicalPath); video.AuthorAvatar = ReplacePrefix(video.AuthorAvatar, repair.RequestedPath, repair.CanonicalPath); if (!string.IsNullOrWhiteSpace(video.DynamicVideos)) video.DynamicVideos = video.DynamicVideos.Replace(repair.RequestedPath + "/", repair.CanonicalPath + "/", StringComparison.Ordinal); await db.Updateable(video).ExecuteCommandAsync(); } var failedItems = await db.Queryable().Where(x => (x.Stage == VideoTaskItemStage.Failed || x.Stage == VideoTaskItemStage.WaitingForStorage) && x.RetrySnapshotJson != null && (x.ErrorType == VideoTaskErrorType.StorageUnavailable || x.ErrorType == VideoTaskErrorType.IntegrityCheckFailed)).ToListAsync(); var taskIds = new HashSet(StringComparer.Ordinal); foreach (var item in failedItems) { if (!(item.TargetPath?.StartsWith(repair.RequestedPath + "/", StringComparison.Ordinal) == true || item.ErrorMessage?.Contains("Operation canceled", StringComparison.OrdinalIgnoreCase) == true || item.ErrorMessage?.Contains("超时", StringComparison.Ordinal) == true || item.ErrorType == VideoTaskErrorType.IntegrityCheckFailed)) continue; item.TargetPath = ReplacePrefix(item.TargetPath, repair.RequestedPath, repair.CanonicalPath); item.RetrySnapshotJson = item.RetrySnapshotJson.Replace(repair.RequestedPath + "/", repair.CanonicalPath + "/", StringComparison.Ordinal); item.Stage = VideoTaskItemStage.Pending; item.ErrorType = VideoTaskErrorType.None; item.ErrorMessage = null; item.CompletedAt = null; item.WorkerManaged = true; item.UpdatedAt = DateTime.Now; taskIds.Add(item.TaskId); } if (failedItems.Count > 0) await db.Updateable(failedItems).ExecuteCommandAsync(); foreach (var taskId in taskIds) { await db.Updateable() .SetColumns(x => new VideoDownloadTask { Status = VideoTaskStatus.Queued, CompletedAt = null, ErrorMessage = null, UpdatedAt = DateTime.Now }).Where(x => x.Id == taskId).ExecuteCommandAsync(); await services.GetRequiredService().RefreshCountsAsync(taskId); } try { _ = await services.GetRequiredService().ProbeStorageAsync(cancellationToken); } catch (Exception ex) { Serilog.Log.Warning(ex, "目录修复后 OpenList 健康检测失败"); } try { var migrations = await db.Queryable().Where(x => x.TargetStorageType == MediaStorageType.OpenList && x.Status == StorageMigrationTaskStatus.PartiallyFailed).ToListAsync(); foreach (var migration in migrations) await services.GetRequiredService().RetryFailedAsync(migration.Id); } catch (Exception ex) { Serilog.Log.Warning(ex, "目录修复后迁移失败项自动重试未能提交"); } try { _ = await services.GetRequiredService() .TriggerVideoJobsNowAsync(VideoTypeEnum.dy_follows); } catch (Exception ex) { Serilog.Log.Warning(ex, "目录修复后关注同步未能自动提交"); } } private static string ReplacePrefix(string value, string oldPrefix, string newPrefix) { if (string.IsNullOrWhiteSpace(value)) return value; return value.StartsWith(oldPrefix + "/", StringComparison.Ordinal) ? newPrefix + value[oldPrefix.Length..] : value; } } }