feat: add fnOS packaging, storage workflows and release pipeline
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
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<OpenListDirectoryRepairWorker> _logger;
|
||||
|
||||
public OpenListDirectoryRepairWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<OpenListDirectoryRepairWorker> 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<ISqlSugarClient>();
|
||||
var now = DateTime.Now;
|
||||
await db.Updateable<OpenListDirectoryRepairItem>()
|
||||
.SetColumns(x => new OpenListDirectoryRepairItem
|
||||
{
|
||||
Status = OpenListDirectoryRepairItemStatus.Pending,
|
||||
ErrorMessage = "应用重启,目录将在恢复后重新检查。",
|
||||
UpdatedAt = now
|
||||
})
|
||||
.Where(x => x.Status == OpenListDirectoryRepairItemStatus.Inspecting).ExecuteCommandAsync();
|
||||
await db.Updateable<OpenListDirectoryRepairItem>()
|
||||
.SetColumns(x => new OpenListDirectoryRepairItem
|
||||
{
|
||||
Status = OpenListDirectoryRepairItemStatus.EmptyConfirmed,
|
||||
ErrorMessage = "应用在删除确认后重启,需再次确认清理。",
|
||||
UpdatedAt = now
|
||||
})
|
||||
.Where(x => x.Status == OpenListDirectoryRepairItemStatus.Deleting).ExecuteCommandAsync();
|
||||
var interrupted = await db.Queryable<OpenListDirectoryRepairTask>().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<OpenListDirectoryRepairService>()
|
||||
.RefreshCountsAsync(task.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> RunOneAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
|
||||
var service = scope.ServiceProvider.GetRequiredService<OpenListDirectoryRepairService>();
|
||||
var task = await db.Queryable<OpenListDirectoryRepairTask>().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<bool> InspectOneAsync(
|
||||
IServiceProvider services,
|
||||
OpenListDirectoryRepairTask task,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var db = services.GetRequiredService<ISqlSugarClient>();
|
||||
var service = services.GetRequiredService<OpenListDirectoryRepairService>();
|
||||
var client = services.GetRequiredService<OpenListClient>();
|
||||
var items = await db.Queryable<OpenListDirectoryRepairItem>().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<ISqlSugarClient>();
|
||||
var service = services.GetRequiredService<OpenListDirectoryRepairService>();
|
||||
await service.RefreshCountsAsync(taskId);
|
||||
var task = await db.Queryable<OpenListDirectoryRepairTask>().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<bool> DeleteOneAsync(
|
||||
IServiceProvider services,
|
||||
OpenListDirectoryRepairTask task,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var db = services.GetRequiredService<ISqlSugarClient>();
|
||||
var service = services.GetRequiredService<OpenListDirectoryRepairService>();
|
||||
var client = services.GetRequiredService<OpenListClient>();
|
||||
var item = await db.Queryable<OpenListDirectoryRepairItem>().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<ISqlSugarClient>();
|
||||
var service = services.GetRequiredService<OpenListDirectoryRepairService>();
|
||||
await service.RefreshCountsAsync(taskId);
|
||||
var task = await db.Queryable<OpenListDirectoryRepairTask>().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<ISqlSugarClient>();
|
||||
var videos = await db.Queryable<DouyinVideo>().Where(x => x.StorageType == MediaStorageType.OpenList
|
||||
&& x.VideoSavePath.StartsWith(repair.RequestedPath + "/")).ToListAsync();
|
||||
var storage = services.GetRequiredService<OpenListMediaStorage>();
|
||||
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<VideoDownloadTaskItem>().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<string>(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<VideoDownloadTask>()
|
||||
.SetColumns(x => new VideoDownloadTask
|
||||
{
|
||||
Status = VideoTaskStatus.Queued,
|
||||
CompletedAt = null,
|
||||
ErrorMessage = null,
|
||||
UpdatedAt = DateTime.Now
|
||||
}).Where(x => x.Id == taskId).ExecuteCommandAsync();
|
||||
await services.GetRequiredService<VideoTaskService>().RefreshCountsAsync(taskId);
|
||||
}
|
||||
|
||||
try { _ = await services.GetRequiredService<VideoTaskService>().ProbeStorageAsync(cancellationToken); }
|
||||
catch (Exception ex) { Serilog.Log.Warning(ex, "目录修复后 OpenList 健康检测失败"); }
|
||||
try
|
||||
{
|
||||
var migrations = await db.Queryable<StorageMigrationTask>().Where(x =>
|
||||
x.TargetStorageType == MediaStorageType.OpenList
|
||||
&& x.Status == StorageMigrationTaskStatus.PartiallyFailed).ToListAsync();
|
||||
foreach (var migration in migrations)
|
||||
await services.GetRequiredService<StorageMigrationService>().RetryFailedAsync(migration.Id);
|
||||
}
|
||||
catch (Exception ex) { Serilog.Log.Warning(ex, "目录修复后迁移失败项自动重试未能提交"); }
|
||||
try
|
||||
{
|
||||
_ = await services.GetRequiredService<DouyinQuartzJobService>()
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user