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 PreflightAsync( OpenListDirectoryRepairPreflightRequest request, CancellationToken cancellationToken) { var plan = await BuildPlanAsync(request?.LogicalPath, cancellationToken); return plan.Result; } public async Task 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().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 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 GetLatestAsync() { var task = await _db.Queryable() .OrderByDescending(x => x.CreatedAt).FirstAsync(); return task == null ? null : await GetAsync(task.Id); } public async Task 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().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() .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() .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().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() .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 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 BuildConfirmationTokenAsync(OpenListDirectoryRepairTask task) { var items = await _db.Queryable().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 RequireTaskAsync(string id) => await _db.Queryable().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 Candidates { get; set; } = new(); } } }