feat: add fnOS packaging, storage workflows and release pipeline

This commit is contained in:
2026-08-11 18:05:49 +08:00
parent c5922f9b08
commit 95932f0199
181 changed files with 24024 additions and 1164 deletions
+351
View File
@@ -0,0 +1,351 @@
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();
}
}
}