352 lines
19 KiB
C#
352 lines
19 KiB
C#
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();
|
||
}
|
||
}
|
||
}
|