Files
douyin/service/OpenListDirectoryRepairService.cs

352 lines
19 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
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 OpenListDirectoryRepairService
{
private readonly ISqlSugarClient _db;
private readonly DouyinCommonService _common;
private readonly OpenListSettingsService _settingsService;
private readonly OpenListClient _client;
public OpenListDirectoryRepairService(
ISqlSugarClient db,
DouyinCommonService common,
OpenListSettingsService settingsService,
OpenListClient client)
{
_db = db;
_common = common;
_settingsService = settingsService;
_client = client;
}
public async Task<OpenListDirectoryRepairPreflightResult> PreflightAsync(
OpenListDirectoryRepairPreflightRequest request,
CancellationToken cancellationToken)
{
var plan = await BuildPlanAsync(request?.LogicalPath, cancellationToken);
return plan.Result;
}
public async Task<OpenListDirectoryRepairTaskDetail> CreateAsync(
CreateOpenListDirectoryRepairRequest request,
CancellationToken cancellationToken)
{
var plan = await BuildPlanAsync(request?.LogicalPath, cancellationToken);
if (!plan.Result.CanStart) throw new InvalidOperationException(string.Join("", plan.Result.Errors));
if (!string.Equals(request?.ConfigurationFingerprint, plan.Result.ConfigurationFingerprint, StringComparison.Ordinal))
throw new InvalidOperationException("OpenList 配置或目录内容已变化,请重新预检。");
if (await _db.Queryable<OpenListDirectoryRepairTask>().Where(x =>
x.Status == OpenListDirectoryRepairStatus.Queued
|| x.Status == OpenListDirectoryRepairStatus.Scanning
|| x.Status == OpenListDirectoryRepairStatus.AwaitingConfirmation
|| x.Status == OpenListDirectoryRepairStatus.Cleaning
|| x.Status == OpenListDirectoryRepairStatus.Paused).AnyAsync())
throw new InvalidOperationException("已有未结束的 OpenList 目录修复任务。");
var now = DateTime.Now;
var task = new OpenListDirectoryRepairTask
{
Id = Guid.NewGuid().ToString("N"),
Status = OpenListDirectoryRepairStatus.Queued,
ConfigurationFingerprint = plan.Result.ConfigurationFingerprint,
RequestedPath = plan.Result.RequestedPath,
CanonicalPath = plan.Result.CanonicalPath,
ActualParentPath = plan.ActualParentPath,
RequestedName = plan.RequestedName,
CandidatePattern = plan.Result.CandidatePattern,
TotalCount = plan.Candidates.Count,
PendingCount = plan.Candidates.Count,
CreatedAt = now,
UpdatedAt = now
};
var items = plan.Candidates.Select((candidate, index) => new OpenListDirectoryRepairItem
{
Id = Guid.NewGuid().ToString("N"),
TaskId = task.Id,
DirectoryName = candidate.Name,
ActualPath = candidate.Path,
Status = OpenListDirectoryRepairItemStatus.Pending,
CreatedAt = now.AddTicks(index),
UpdatedAt = now
}).ToList();
var transaction = await _db.Ado.UseTranAsync(async () =>
{
await _db.Insertable(task).ExecuteCommandAsync();
if (items.Count > 0) await _db.Insertable(items).ExecuteCommandAsync();
});
if (!transaction.IsSuccess)
throw transaction.ErrorException ?? new InvalidOperationException("创建目录修复任务失败。");
return await GetAsync(task.Id);
}
public async Task<OpenListDirectoryRepairTaskDetail> GetAsync(string id)
{
var task = await RequireTaskAsync(id);
var token = task.Status == OpenListDirectoryRepairStatus.AwaitingConfirmation
? await BuildConfirmationTokenAsync(task) : null;
return new OpenListDirectoryRepairTaskDetail
{
Task = task,
StatusText = StatusText(task.Status),
ConfirmationToken = token,
CanConfirmCleanup = task.Status == OpenListDirectoryRepairStatus.AwaitingConfirmation && task.EmptyCount > 0,
CanCancel = task.Status is OpenListDirectoryRepairStatus.Queued or OpenListDirectoryRepairStatus.Scanning
or OpenListDirectoryRepairStatus.AwaitingConfirmation or OpenListDirectoryRepairStatus.Cleaning,
CanResume = task.Status is OpenListDirectoryRepairStatus.Cancelled or OpenListDirectoryRepairStatus.Paused,
CanRetryFailed = task.FailedCount > 0 && task.Status is (OpenListDirectoryRepairStatus.PartiallyFailed
or OpenListDirectoryRepairStatus.Cancelled or OpenListDirectoryRepairStatus.Paused)
};
}
public async Task<OpenListDirectoryRepairTaskDetail> GetLatestAsync()
{
var task = await _db.Queryable<OpenListDirectoryRepairTask>()
.OrderByDescending(x => x.CreatedAt).FirstAsync();
return task == null ? null : await GetAsync(task.Id);
}
public async Task<OpenListDirectoryRepairItemPage> GetItemsAsync(
string id,
OpenListDirectoryRepairItemPageRequest request)
{
_ = await RequireTaskAsync(id);
request ??= new OpenListDirectoryRepairItemPageRequest();
var page = Math.Max(1, request.PageIndex);
var size = Math.Clamp(request.PageSize, 1, 100);
var query = _db.Queryable<OpenListDirectoryRepairItem>().Where(x => x.TaskId == id)
.WhereIF(request.Status.HasValue, x => x.Status == request.Status.Value)
.WhereIF(!string.IsNullOrWhiteSpace(request.Keyword), x =>
x.DirectoryName.Contains(request.Keyword) || x.ErrorMessage.Contains(request.Keyword));
return new OpenListDirectoryRepairItemPage
{
TotalCount = await query.CountAsync(),
Items = await query.OrderBy(x => x.CreatedAt).Skip((page - 1) * size).Take(size).ToListAsync()
};
}
public async Task ConfirmCleanupAsync(
string id,
ConfirmOpenListDirectoryRepairRequest request)
{
var task = await RequireTaskAsync(id);
if (task.Status != OpenListDirectoryRepairStatus.AwaitingConfirmation)
throw new InvalidOperationException("只有扫描完成并等待确认的任务可以开始清理。");
await EnsureFingerprintAsync(task);
var token = await BuildConfirmationTokenAsync(task);
if (string.IsNullOrWhiteSpace(request?.ConfirmationToken)
|| !CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(token), Encoding.UTF8.GetBytes(request.ConfirmationToken)))
throw new InvalidOperationException("目录状态已变化,请刷新任务详情后重新确认。");
if (task.EmptyCount <= 0) throw new InvalidOperationException("没有已确认为空的目录可以清理。");
task.Status = OpenListDirectoryRepairStatus.Cleaning;
task.ErrorMessage = null;
task.CompletedAt = null;
task.UpdatedAt = DateTime.Now;
await _db.Updateable(task).ExecuteCommandAsync();
}
public async Task CancelAsync(string id)
{
var task = await RequireTaskAsync(id);
if (task.Status is not (OpenListDirectoryRepairStatus.Queued or OpenListDirectoryRepairStatus.Scanning
or OpenListDirectoryRepairStatus.AwaitingConfirmation or OpenListDirectoryRepairStatus.Cleaning))
throw new InvalidOperationException("该任务当前不能取消。");
task.Status = OpenListDirectoryRepairStatus.Cancelled;
task.CurrentDirectory = null;
task.CompletedAt = DateTime.Now;
task.UpdatedAt = DateTime.Now;
await _db.Updateable(task).ExecuteCommandAsync();
}
public async Task ResumeAsync(string id)
{
var task = await RequireTaskAsync(id);
if (task.Status is not (OpenListDirectoryRepairStatus.Cancelled or OpenListDirectoryRepairStatus.Paused))
throw new InvalidOperationException("只有已取消或因配置变化暂停的任务可以恢复。");
await EnsureFingerprintAsync(task);
await _db.Updateable<OpenListDirectoryRepairItem>()
.SetColumns(x => new OpenListDirectoryRepairItem
{
Status = OpenListDirectoryRepairItemStatus.Pending,
ErrorMessage = null,
CompletedAt = null,
UpdatedAt = DateTime.Now
})
.Where(x => x.TaskId == id && (x.Status == OpenListDirectoryRepairItemStatus.Inspecting
|| x.Status == OpenListDirectoryRepairItemStatus.Deleting)).ExecuteCommandAsync();
await RefreshCountsAsync(id);
task = await RequireTaskAsync(id);
task.Status = task.EmptyCount > 0 && task.PendingCount == 0
? OpenListDirectoryRepairStatus.AwaitingConfirmation
: OpenListDirectoryRepairStatus.Queued;
task.CurrentDirectory = null;
task.CompletedAt = null;
task.ErrorMessage = null;
task.UpdatedAt = DateTime.Now;
await _db.Updateable(task).ExecuteCommandAsync();
}
public async Task RetryFailedAsync(string id)
{
var task = await RequireTaskAsync(id);
if (task.Status is not (OpenListDirectoryRepairStatus.PartiallyFailed
or OpenListDirectoryRepairStatus.Cancelled or OpenListDirectoryRepairStatus.Paused))
throw new InvalidOperationException("该任务当前不能重试失败项。");
await EnsureFingerprintAsync(task);
var changed = await _db.Updateable<OpenListDirectoryRepairItem>()
.SetColumns(x => new OpenListDirectoryRepairItem
{
Status = OpenListDirectoryRepairItemStatus.Pending,
ErrorMessage = null,
CompletedAt = null,
UpdatedAt = DateTime.Now
})
.Where(x => x.TaskId == id && x.Status == OpenListDirectoryRepairItemStatus.Failed)
.ExecuteCommandAsync();
if (changed == 0) throw new InvalidOperationException("没有可重试的失败目录。");
task.Status = OpenListDirectoryRepairStatus.Queued;
task.CompletedAt = null;
task.ErrorMessage = null;
task.UpdatedAt = DateTime.Now;
await _db.Updateable(task).ExecuteCommandAsync();
await RefreshCountsAsync(id);
}
public async Task RefreshCountsAsync(string id)
{
var items = await _db.Queryable<OpenListDirectoryRepairItem>().Where(x => x.TaskId == id).ToListAsync();
var pending = items.Count(x => x.Status is OpenListDirectoryRepairItemStatus.Pending
or OpenListDirectoryRepairItemStatus.Inspecting or OpenListDirectoryRepairItemStatus.Deleting);
await _db.Updateable<OpenListDirectoryRepairTask>()
.SetColumns(x => new OpenListDirectoryRepairTask
{
TotalCount = items.Count,
PendingCount = pending,
EmptyCount = items.Count(x => x.Status == OpenListDirectoryRepairItemStatus.EmptyConfirmed),
DeletedCount = items.Count(x => x.Status == OpenListDirectoryRepairItemStatus.Deleted),
SkippedCount = items.Count(x => x.Status == OpenListDirectoryRepairItemStatus.SkippedNonEmpty),
FailedCount = items.Count(x => x.Status == OpenListDirectoryRepairItemStatus.Failed),
MissingCount = items.Count(x => x.Status == OpenListDirectoryRepairItemStatus.Missing),
UpdatedAt = DateTime.Now
}).Where(x => x.Id == id).ExecuteCommandAsync();
}
public async Task EnsureFingerprintAsync(OpenListDirectoryRepairTask task)
{
var settings = await _settingsService.GetAsync();
var current = StorageConfigurationFingerprint.Create(settings);
if (!string.Equals(current, task.ConfigurationFingerprint, StringComparison.Ordinal))
throw new InvalidOperationException("OpenList 配置已变化,请恢复原配置或重新创建目录修复任务。");
}
public async Task<(OpenListSettings Settings, string Password)> GetConnectionAsync()
{
var settings = await _settingsService.GetAsync();
var password = await _settingsService.GetPasswordAsync(settings);
if (string.IsNullOrWhiteSpace(password)) throw new InvalidOperationException("OpenList 密码无效,请重新保存配置。");
return (settings, password);
}
private async Task<RepairPlan> BuildPlanAsync(string logicalPath, CancellationToken cancellationToken)
{
var result = new OpenListDirectoryRepairPreflightResult();
if ((_common.GetConfig()?.StorageType ?? MediaStorageType.Local) != MediaStorageType.OpenList)
{
result.Errors.Add("当前存储不是 OpenList,不能执行目录修复。");
return new RepairPlan(result);
}
logicalPath = StoragePath.NormalizeRemote(string.IsNullOrWhiteSpace(logicalPath) ? "/collect/Kk" : logicalPath);
if (logicalPath == "/" || logicalPath.Count(x => x == '/') < 2)
{
result.Errors.Add("目录修复路径必须位于 OpenList 基础目录的子目录中。");
return new RepairPlan(result);
}
var (settings, password) = await GetConnectionAsync();
result.ConfigurationFingerprint = StorageConfigurationFingerprint.Create(settings);
result.RequestedPath = logicalPath;
var requestedActual = OpenListTransferService.ToActualPath(settings, logicalPath);
var canonicalActual = await _client.ResolveCanonicalObjectPathAsync(settings, password,
requestedActual, false, cancellationToken);
var info = await _client.TryGetObjectAsync(settings, password, canonicalActual, cancellationToken);
if (info is not { IsDirectory: true })
{
result.Errors.Add($"没有找到可复用的规范目录:{logicalPath}。");
return new RepairPlan(result);
}
var requestedName = Path.GetFileName(logicalPath);
var canonicalName = info.Name;
if (string.Equals(requestedName, canonicalName, StringComparison.Ordinal)
|| !string.Equals(requestedName, canonicalName, StringComparison.OrdinalIgnoreCase))
{
result.Errors.Add($"路径不存在纯大小写冲突:请求 {requestedName},实际 {canonicalName}。");
return new RepairPlan(result);
}
var actualParent = StoragePath.DirectoryName(canonicalActual);
var escaped = Regex.Escape(requestedName);
var pattern = $"^{escaped}_\\d{{8}}_\\d{{6}}$";
var regex = new Regex(pattern, RegexOptions.CultureInvariant | RegexOptions.Compiled);
var listing = await _client.ListDirectoriesAsync(settings, password, actualParent, true, cancellationToken);
var candidates = listing.Directories.Where(x => regex.IsMatch(x.Name))
.OrderBy(x => x.Name, StringComparer.Ordinal).ToList();
result.CanonicalPath = OpenListTransferService.ToLogicalPath(settings, canonicalActual);
result.CandidatePattern = pattern;
result.CandidateCount = candidates.Count;
result.CanStart = candidates.Count > 0;
if (candidates.Count == 0) result.Errors.Add("没有找到匹配的时间后缀异常目录。");
else result.Warnings.Add($"找到 {candidates.Count} 个候选目录;后台任务会逐个确认为空,非空目录绝不会删除。");
return new RepairPlan(result)
{
ActualParentPath = actualParent,
RequestedName = requestedName,
Candidates = candidates
};
}
private async Task<string> BuildConfirmationTokenAsync(OpenListDirectoryRepairTask task)
{
var items = await _db.Queryable<OpenListDirectoryRepairItem>().Where(x => x.TaskId == task.Id
&& x.Status == OpenListDirectoryRepairItemStatus.EmptyConfirmed)
.OrderBy(x => x.Id).ToListAsync();
var source = task.Id + "\n" + task.ConfigurationFingerprint + "\n"
+ string.Join("\n", items.Select(x => $"{x.Id}|{x.ActualPath}|{x.UpdatedAt.Ticks}|{x.EntryCount}"));
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source))).ToLowerInvariant();
}
private async Task<OpenListDirectoryRepairTask> RequireTaskAsync(string id) =>
await _db.Queryable<OpenListDirectoryRepairTask>().InSingleAsync(id)
?? throw new KeyNotFoundException("目录修复任务不存在。");
private static string StatusText(OpenListDirectoryRepairStatus status) => status switch
{
OpenListDirectoryRepairStatus.Queued => "等待扫描",
OpenListDirectoryRepairStatus.Scanning => "扫描中",
OpenListDirectoryRepairStatus.AwaitingConfirmation => "等待确认清理",
OpenListDirectoryRepairStatus.Cleaning => "清理中",
OpenListDirectoryRepairStatus.Completed => "已完成",
OpenListDirectoryRepairStatus.PartiallyFailed => "部分失败",
OpenListDirectoryRepairStatus.Cancelled => "已取消",
OpenListDirectoryRepairStatus.Paused => "配置变化,已暂停",
_ => status.ToString()
};
private sealed class RepairPlan
{
public RepairPlan(OpenListDirectoryRepairPreflightResult result) => Result = result;
public OpenListDirectoryRepairPreflightResult Result { get; }
public string ActualParentPath { get; set; }
public string RequestedName { get; set; }
public List<OpenListDirectoryItemDto> Candidates { get; set; } = new();
}
}
}