From a68bb6c4b3fbf0068e2fe65bf906ea56ba962e05 Mon Sep 17 00:00:00 2001 From: luoqian <2769838458@qq.com> Date: Thu, 21 May 2026 16:45:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=BA=93=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E5=B1=80?= =?UTF-8?q?=E5=9F=9F=E7=BD=91=E6=96=87=E4=BB=B6=E6=B5=8F=E8=A7=88=E4=B8=8E?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 新增 ManagedLibraryRoot / ManagedFileRecord 数据模型及 SQLite 迁移 - 新增文件库服务、端点服务及定时扫描后台任务 - 新增 REST API: drives、directories、roots CRUD、files 分页搜索、文本预览 - 新增文件流端点支持视频/音频流式传输 - 数据库切换为 SQLite,Kestrel 绑定 0.0.0.0 支持局域网访问 前端: - 管理端:磁盘浏览、目录选择、根目录添加/启用/删除/扫描 - 客户端:根目录选择、文件搜索/筛选/分页、音视频播放、文本预览 - 全新响应式 UI(桌面+移动端),CSS 变量设计系统 - HTTP 客户端支持 Vite 开发代理与生产同源自动切换 - 移除 HTTPS 强制重定向以提升移动端视频流兼容性 --- .gitignore | 4 + Avalonia-API/Avalonia-API.csproj | 19 + .../Configuration/ServicesConfiguration.cs | 8 + .../FileStreamEndpointExtensions.cs | 47 ++ .../Extensions/UnifiedEndpointExtensions.cs | 9 +- Avalonia-API/Program.cs | 17 +- Avalonia-API/Properties/launchSettings.json | 4 +- .../Services/FileLibraryScanHostedService.cs | 38 + Avalonia-API/appsettings.json | 4 +- Avalonia-EFCore/Database/AppDataContext.cs | 34 + .../20260521080213_AddFileLibrary.Designer.cs | 352 ++++++++++ .../SQLite/20260521080213_AddFileLibrary.cs | 102 +++ .../SqliteAppDataContextModelSnapshot.cs | 173 +++++ Avalonia-EFCore/Models/ManagedFileRecord.cs | 81 +++ Avalonia-EFCore/Models/ManagedLibraryRoot.cs | 66 ++ Avalonia-Services/Endpoints/AppEndpoints.cs | 41 ++ .../FileLibrary/FileLibraryContracts.cs | 64 ++ .../FileLibrary/FileLibraryEndpointService.cs | 99 +++ .../FileLibrary/FileLibraryService.cs | 409 +++++++++++ .../IFileLibraryEndpointService.cs | 27 + .../FileLibrary/IFileLibraryService.cs | 30 + .../Services/FileLibrary/MediaFileTypes.cs | 51 ++ Avalonia-Web-VUE/src/App.vue | 466 +++++++++++-- Avalonia-Web-VUE/src/api/http.ts | 26 +- Avalonia-Web-VUE/src/api/index.ts | 91 ++- Avalonia-Web-VUE/src/assets/main.css | 659 +++++++++++++++++- Avalonia-Web-VUE/vite.config.ts | 16 +- app.db | Bin 0 -> 4096 bytes app.db-shm | Bin 0 -> 32768 bytes app.db-wal | Bin 0 -> 189552 bytes logs/log-20260521.txt | 310 ++++++++ 31 files changed, 3162 insertions(+), 85 deletions(-) create mode 100644 Avalonia-API/Extensions/FileStreamEndpointExtensions.cs create mode 100644 Avalonia-API/Services/FileLibraryScanHostedService.cs create mode 100644 Avalonia-EFCore/Migrations/SQLite/20260521080213_AddFileLibrary.Designer.cs create mode 100644 Avalonia-EFCore/Migrations/SQLite/20260521080213_AddFileLibrary.cs create mode 100644 Avalonia-EFCore/Models/ManagedFileRecord.cs create mode 100644 Avalonia-EFCore/Models/ManagedLibraryRoot.cs create mode 100644 Avalonia-Services/Services/FileLibrary/FileLibraryContracts.cs create mode 100644 Avalonia-Services/Services/FileLibrary/FileLibraryEndpointService.cs create mode 100644 Avalonia-Services/Services/FileLibrary/FileLibraryService.cs create mode 100644 Avalonia-Services/Services/FileLibrary/IFileLibraryEndpointService.cs create mode 100644 Avalonia-Services/Services/FileLibrary/IFileLibraryService.cs create mode 100644 Avalonia-Services/Services/FileLibrary/MediaFileTypes.cs create mode 100644 app.db create mode 100644 app.db-shm create mode 100644 app.db-wal create mode 100644 logs/log-20260521.txt diff --git a/.gitignore b/.gitignore index 15db508..24a9d74 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,9 @@ /Avalonia-API/avalonia-api.db /Avalonia-API/avalonia-api.db-shm /Avalonia-API/avalonia-api.db-wal +/Avalonia-API/app.db +/Avalonia-API/app.db-shm +/Avalonia-API/app.db-wal +/Avalonia-API/wwwroot /package-output /package-scripts/tools diff --git a/Avalonia-API/Avalonia-API.csproj b/Avalonia-API/Avalonia-API.csproj index 8bbb938..59c4954 100644 --- a/Avalonia-API/Avalonia-API.csproj +++ b/Avalonia-API/Avalonia-API.csproj @@ -28,4 +28,23 @@ + + + + + + + + + + + + + + + + diff --git a/Avalonia-API/Configuration/ServicesConfiguration.cs b/Avalonia-API/Configuration/ServicesConfiguration.cs index f8f8746..34044c9 100644 --- a/Avalonia-API/Configuration/ServicesConfiguration.cs +++ b/Avalonia-API/Configuration/ServicesConfiguration.cs @@ -1,9 +1,12 @@ using Avalonia_API.Authentication; +using Avalonia_API.Services; using Avalonia_EFCore.Database; using Avalonia_Services.Core; using Avalonia_Services.Endpoints; using Avalonia_Services.Services; using Avalonia_Services.Services.AuthService; +using Avalonia_Services.Services.FileLibrary; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using System.Text; @@ -36,6 +39,11 @@ namespace Avalonia_API.Configuration // ---- 业务服务 ---- services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddHostedService(); + services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "data-protection-keys"))); // ---- API 鉴权 ---- var jwtSection = configuration.GetSection("Jwt"); diff --git a/Avalonia-API/Extensions/FileStreamEndpointExtensions.cs b/Avalonia-API/Extensions/FileStreamEndpointExtensions.cs new file mode 100644 index 0000000..2e99e4a --- /dev/null +++ b/Avalonia-API/Extensions/FileStreamEndpointExtensions.cs @@ -0,0 +1,47 @@ +using Avalonia_EFCore.Database; +using Microsoft.EntityFrameworkCore; + +namespace Avalonia_API.Extensions +{ + public static class FileStreamEndpointExtensions + { + public static IEndpointRouteBuilder MapFileStreamEndpoints(this IEndpointRouteBuilder app) + { + app.MapMethods("/api/files/{id:int}/stream", ["GET", "HEAD"], async (int id, AppDataContext db, HttpContext httpContext) => + { + // Browsers cancel in-flight range requests aggressively while seeking. + // Keep this small metadata lookup independent from RequestAborted so + // EF does not throw TaskCanceledException before the file is opened. + var file = await db.ManagedFileRecords + .AsNoTracking() + .Include(item => item.LibraryRoot) + .FirstOrDefaultAsync(item => + item.Id == id + && item.Exists + && item.LibraryRoot != null + && item.LibraryRoot.IsAvailable); + + if (file is null || !System.IO.File.Exists(file.AbsolutePath)) + { + return Results.NotFound(); + } + + var stream = System.IO.File.Open(file.AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + httpContext.Response.Headers.ContentDisposition = $"inline; filename=\"{Uri.EscapeDataString(file.FileName)}\""; + httpContext.Response.Headers.AcceptRanges = "bytes"; + httpContext.Response.Headers.CacheControl = "public, max-age=3600"; + + return Results.File( + stream, + contentType: file.ContentType, + fileDownloadName: null, + lastModified: file.LastWriteTimeUtc, + enableRangeProcessing: true); + }) + .WithName("StreamManagedFile") + .WithTags("FileLibrary"); + + return app; + } + } +} diff --git a/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs b/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs index 234541f..76889fc 100644 --- a/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs +++ b/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs @@ -131,9 +131,11 @@ namespace Avalonia_API.Extensions { return async (HttpContext httpContext) => { - var ctx = await BuildContextFromHttpContext(httpContext); + var ctx = httpContext.Items["UnifiedContext"] as ServiceEndpointContext + ?? await BuildContextFromHttpContext(httpContext); ctx.Items["ServiceProvider"] = serviceProvider; ctx.Items["User"] = httpContext.User; + httpContext.Items["UnifiedContext"] = ctx; var result = await unifiedHandler(ctx); @@ -204,6 +206,7 @@ namespace Avalonia_API.Extensions httpContext.Items["UnifiedContext"] = ctx; + object? nextResult = null; await unifiedFilter.InvokeAsync(ctx, async (c) => { httpContext.Response.StatusCode = c.StatusCode; @@ -211,7 +214,7 @@ namespace Avalonia_API.Extensions { httpContext.Response.Headers[kvp.Key] = kvp.Value; } - await aspNext(aspContext); + nextResult = await aspNext(aspContext); }); if (ctx.ResponseBody is not null) @@ -219,7 +222,7 @@ namespace Avalonia_API.Extensions return Results.Json(ctx.ResponseBody, statusCode: ctx.StatusCode); } - return null!; + return nextResult; } } } diff --git a/Avalonia-API/Program.cs b/Avalonia-API/Program.cs index 506a365..385bd7b 100644 --- a/Avalonia-API/Program.cs +++ b/Avalonia-API/Program.cs @@ -13,12 +13,22 @@ try { var builder = WebApplication.CreateBuilder(args); + // 配置 Kestrel 监听所有本机 IP + builder.WebHost.UseUrls("http://0.0.0.0:5206", "https://0.0.0.0:7165"); + // 使用 Serilog 作为日志提供程序 builder.Host.UseSerilog(); // Add services to the container. builder.Services.AddControllers(); builder.Services.AddOpenApi(); + builder.Services.AddCors(options => + { + options.AddPolicy("LanFileViewer", policy => + policy.AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod()); + }); // 注册统一端点及业务服务(入口在 Avalonia-Services/Endpoints/AppEndpoints.cs) builder.Services.AddUnifiedApiServices(builder.Configuration); @@ -41,12 +51,17 @@ try }); } - app.UseHttpsRedirection(); + app.UseDefaultFiles(); + app.UseStaticFiles(); + // 局域网文件播放优先使用 HTTP,避免手机浏览器对自签 HTTPS/HTTP2 视频流的兼容问题。 + app.UseCors("LanFileViewer"); app.UseAuthentication(); app.UseAuthorization(); // 将统一端点映射到 ASP.NET Core 路由 app.MapUnifiedEndpoints(endpoints, app.Services); + app.MapFileStreamEndpoints(); + app.MapFallbackToFile("index.html"); app.Run(); } diff --git a/Avalonia-API/Properties/launchSettings.json b/Avalonia-API/Properties/launchSettings.json index 00c28bd..c448497 100644 --- a/Avalonia-API/Properties/launchSettings.json +++ b/Avalonia-API/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:5206", + "applicationUrl": "http://0.0.0.0:5206", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://localhost:7165;http://localhost:5206", + "applicationUrl": "https://0.0.0.0:7165;http://0.0.0.0:5206", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/Avalonia-API/Services/FileLibraryScanHostedService.cs b/Avalonia-API/Services/FileLibraryScanHostedService.cs new file mode 100644 index 0000000..64c57f8 --- /dev/null +++ b/Avalonia-API/Services/FileLibraryScanHostedService.cs @@ -0,0 +1,38 @@ +using Avalonia_Services.Services.FileLibrary; + +namespace Avalonia_API.Services +{ + public sealed class FileLibraryScanHostedService(IServiceScopeFactory scopeFactory, ILogger logger) + : BackgroundService + { + private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await ScanAsync(stoppingToken); + + using var timer = new PeriodicTimer(Interval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await ScanAsync(stoppingToken); + } + } + + private async Task ScanAsync(CancellationToken cancellationToken) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var scanner = scope.ServiceProvider.GetRequiredService(); + await scanner.ScanDueRootsAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + logger.LogWarning(ex, "文件库定时扫描失败。"); + } + } + } +} diff --git a/Avalonia-API/appsettings.json b/Avalonia-API/appsettings.json index 91235d1..221f6ef 100644 --- a/Avalonia-API/appsettings.json +++ b/Avalonia-API/appsettings.json @@ -14,8 +14,8 @@ "RefreshTokenDays": 30 }, "DatabaseConfiguration": { - "Provider": "MySQL", - "ConnectionString": "Server=127.0.0.1;Port=3306;Database=avalonia-api;Uid=root;Pwd=123456;Max Pool Size=100;Min Pool Size=5;AllowZeroDateTime=True;AllowLoadLocalInfile=true;SslMode=Required", + "Provider": "SQLite", + "ConnectionString": "Data Source=app.db", "AutoMigrate": true, "RecreateDatabase": false, "EnableDetailedLog": false, diff --git a/Avalonia-EFCore/Database/AppDataContext.cs b/Avalonia-EFCore/Database/AppDataContext.cs index e8c5f09..a495f54 100644 --- a/Avalonia-EFCore/Database/AppDataContext.cs +++ b/Avalonia-EFCore/Database/AppDataContext.cs @@ -19,6 +19,12 @@ namespace Avalonia_EFCore.Database /// API refresh token 数据 public DbSet ApiRefreshTokens => Set(); + /// 文件库根目录数据 + public DbSet ManagedLibraryRoots => Set(); + + /// 文件库文件记录数据 + public DbSet ManagedFileRecords => Set(); + /// /// 配置实体映射,包括主键、索引和属性约束。 /// @@ -45,6 +51,34 @@ namespace Avalonia_EFCore.Database entity.HasIndex(e => e.TokenHash).IsUnique().HasDatabaseName("idx-api-refresh-token-hash"); entity.HasIndex(e => e.UserId).HasDatabaseName("idx-api-refresh-token-user-id"); }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("pk-managed-library-root"); + entity.HasIndex(e => e.Path).IsUnique().HasDatabaseName("idx-managed-library-root-path"); + entity.Property(e => e.Path).HasMaxLength(1024); + entity.Property(e => e.DisplayName).HasMaxLength(200); + entity.Property(e => e.LastScanError).HasMaxLength(2000); + entity.Property(e => e.IsAvailable).HasDefaultValue(true); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("pk-managed-file-record"); + entity.HasIndex(e => e.LibraryRootId).HasDatabaseName("idx-managed-file-record-root-id"); + entity.HasIndex(e => e.AbsolutePath).IsUnique().HasDatabaseName("idx-managed-file-record-absolute-path"); + entity.HasIndex(e => new { e.MediaType, e.Exists }).HasDatabaseName("idx-managed-file-record-media-type-exists"); + entity.Property(e => e.FileName).HasMaxLength(260); + entity.Property(e => e.RelativePath).HasMaxLength(1024); + entity.Property(e => e.AbsolutePath).HasMaxLength(2048); + entity.Property(e => e.Extension).HasMaxLength(32); + entity.Property(e => e.MediaType).HasMaxLength(20); + entity.Property(e => e.ContentType).HasMaxLength(100); + entity.HasOne(e => e.LibraryRoot) + .WithMany(e => e.Files) + .HasForeignKey(e => e.LibraryRootId) + .OnDelete(DeleteBehavior.Cascade); + }); } } } diff --git a/Avalonia-EFCore/Migrations/SQLite/20260521080213_AddFileLibrary.Designer.cs b/Avalonia-EFCore/Migrations/SQLite/20260521080213_AddFileLibrary.Designer.cs new file mode 100644 index 0000000..17f8349 --- /dev/null +++ b/Avalonia-EFCore/Migrations/SQLite/20260521080213_AddFileLibrary.Designer.cs @@ -0,0 +1,352 @@ +// +using System; +using Avalonia_EFCore.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Avalonia_EFCore.Migrations.SQLite +{ + [DbContext(typeof(SqliteAppDataContext))] + [Migration("20260521080213_AddFileLibrary")] + partial class AddFileLibrary + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at"); + + b.Property("Device") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("device"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT") + .HasColumnName("expires-at"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("ip-address"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("replaced-by-token-hash"); + + b.Property("RevokedAt") + .HasColumnType("TEXT") + .HasColumnName("revoked-at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("token-hash"); + + b.Property("UserId") + .HasColumnType("INTEGER") + .HasColumnName("user-id"); + + b.HasKey("Id") + .HasName("pk-api-refresh-token"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("idx-api-refresh-token-hash"); + + b.HasIndex("UserId") + .HasDatabaseName("idx-api-refresh-token-user-id"); + + b.ToTable("api-refresh-token", t => + { + t.HasComment("API refresh token"); + }); + }); + + modelBuilder.Entity("Avalonia_EFCore.Models.ManagedFileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("AbsolutePath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT") + .HasColumnName("absolute-path"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("content-type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at"); + + b.Property("Exists") + .HasColumnType("INTEGER") + .HasColumnName("exists"); + + b.Property("Extension") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasColumnName("extension"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("TEXT") + .HasColumnName("file-name"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT") + .HasColumnName("last-seen-at"); + + b.Property("LastWriteTimeUtc") + .HasColumnType("TEXT") + .HasColumnName("last-write-time-utc"); + + b.Property("LibraryRootId") + .HasColumnType("INTEGER") + .HasColumnName("library-root-id"); + + b.Property("MediaType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("media-type"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT") + .HasColumnName("relative-path"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER") + .HasColumnName("size-bytes"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated-at"); + + b.HasKey("Id") + .HasName("pk-managed-file-record"); + + b.HasIndex("AbsolutePath") + .IsUnique() + .HasDatabaseName("idx-managed-file-record-absolute-path"); + + b.HasIndex("LibraryRootId") + .HasDatabaseName("idx-managed-file-record-root-id"); + + b.HasIndex("MediaType", "Exists") + .HasDatabaseName("idx-managed-file-record-media-type-exists"); + + b.ToTable("managed-file-record", t => + { + t.HasComment("文件库文件记录"); + }); + }); + + modelBuilder.Entity("Avalonia_EFCore.Models.ManagedLibraryRoot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("display-name"); + + b.Property("IsAvailable") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("is-available"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is-enabled"); + + b.Property("LastScanCompletedAt") + .HasColumnType("TEXT") + .HasColumnName("last-scan-completed-at"); + + b.Property("LastScanError") + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("last-scan-error"); + + b.Property("LastScanStartedAt") + .HasColumnType("TEXT") + .HasColumnName("last-scan-started-at"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("ScanIntervalMinutes") + .HasColumnType("INTEGER") + .HasColumnName("scan-interval-minutes"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated-at"); + + b.HasKey("Id") + .HasName("pk-managed-library-root"); + + b.HasIndex("Path") + .IsUnique() + .HasDatabaseName("idx-managed-library-root-path"); + + b.ToTable("managed-library-root", t => + { + t.HasComment("文件库根目录"); + }); + }); + + modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasComment("用户主键"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at") + .HasComment("创建时间"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("email") + .HasComment("用户邮箱"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name") + .HasComment("用户名称"); + + b.Property("PasswordHash") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("password-hash") + .HasComment("密码哈希值"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("phone-number") + .HasComment("电话号码"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated-at") + .HasComment("更新时间"); + + b.HasKey("Id") + .HasName("pk-user"); + + b.ToTable("user", t => + { + t.HasComment("用户实体,演示数据库 CRUD 操作"); + }); + }); + + modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasComment("天气预报主键"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at") + .HasComment("创建时间"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date") + .HasComment("预报日期"); + + b.Property("Summary") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("summary") + .HasComment("天气摘要"); + + b.Property("TemperatureC") + .HasColumnType("INTEGER") + .HasColumnName("temperature-c") + .HasComment("摄氏温度"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated-at") + .HasComment("更新时间"); + + b.HasKey("Id") + .HasName("pk-weather-forecast"); + + b.ToTable("weather-forecast", t => + { + t.HasComment("天气预报数据实体"); + }); + }); + + modelBuilder.Entity("Avalonia_EFCore.Models.ManagedFileRecord", b => + { + b.HasOne("Avalonia_EFCore.Models.ManagedLibraryRoot", "LibraryRoot") + .WithMany("Files") + .HasForeignKey("LibraryRootId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LibraryRoot"); + }); + + modelBuilder.Entity("Avalonia_EFCore.Models.ManagedLibraryRoot", b => + { + b.Navigation("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Avalonia-EFCore/Migrations/SQLite/20260521080213_AddFileLibrary.cs b/Avalonia-EFCore/Migrations/SQLite/20260521080213_AddFileLibrary.cs new file mode 100644 index 0000000..682cca3 --- /dev/null +++ b/Avalonia-EFCore/Migrations/SQLite/20260521080213_AddFileLibrary.cs @@ -0,0 +1,102 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Avalonia_EFCore.Migrations.SQLite +{ + /// + public partial class AddFileLibrary : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "managed-library-root", + columns: table => new + { + id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + path = table.Column(type: "TEXT", maxLength: 1024, nullable: false), + displayname = table.Column(name: "display-name", type: "TEXT", maxLength: 200, nullable: false), + isenabled = table.Column(name: "is-enabled", type: "INTEGER", nullable: false), + isavailable = table.Column(name: "is-available", type: "INTEGER", nullable: false, defaultValue: true), + scanintervalminutes = table.Column(name: "scan-interval-minutes", type: "INTEGER", nullable: false), + lastscanstartedat = table.Column(name: "last-scan-started-at", type: "TEXT", nullable: true), + lastscancompletedat = table.Column(name: "last-scan-completed-at", type: "TEXT", nullable: true), + lastscanerror = table.Column(name: "last-scan-error", type: "TEXT", maxLength: 2000, nullable: true), + createdat = table.Column(name: "created-at", type: "TEXT", nullable: false), + updatedat = table.Column(name: "updated-at", type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk-managed-library-root", x => x.id); + }, + comment: "文件库根目录"); + + migrationBuilder.CreateTable( + name: "managed-file-record", + columns: table => new + { + id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + libraryrootid = table.Column(name: "library-root-id", type: "INTEGER", nullable: false), + filename = table.Column(name: "file-name", type: "TEXT", maxLength: 260, nullable: false), + relativepath = table.Column(name: "relative-path", type: "TEXT", maxLength: 1024, nullable: false), + absolutepath = table.Column(name: "absolute-path", type: "TEXT", maxLength: 2048, nullable: false), + extension = table.Column(type: "TEXT", maxLength: 32, nullable: false), + sizebytes = table.Column(name: "size-bytes", type: "INTEGER", nullable: false), + lastwritetimeutc = table.Column(name: "last-write-time-utc", type: "TEXT", nullable: false), + mediatype = table.Column(name: "media-type", type: "TEXT", maxLength: 20, nullable: false), + contenttype = table.Column(name: "content-type", type: "TEXT", maxLength: 100, nullable: false), + exists = table.Column(type: "INTEGER", nullable: false), + lastseenat = table.Column(name: "last-seen-at", type: "TEXT", nullable: false), + createdat = table.Column(name: "created-at", type: "TEXT", nullable: false), + updatedat = table.Column(name: "updated-at", type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk-managed-file-record", x => x.id); + table.ForeignKey( + name: "FK_managed-file-record_managed-library-root_library-root-id", + column: x => x.libraryrootid, + principalTable: "managed-library-root", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }, + comment: "文件库文件记录"); + + migrationBuilder.CreateIndex( + name: "idx-managed-file-record-absolute-path", + table: "managed-file-record", + column: "absolute-path", + unique: true); + + migrationBuilder.CreateIndex( + name: "idx-managed-file-record-media-type-exists", + table: "managed-file-record", + columns: new[] { "media-type", "exists" }); + + migrationBuilder.CreateIndex( + name: "idx-managed-file-record-root-id", + table: "managed-file-record", + column: "library-root-id"); + + migrationBuilder.CreateIndex( + name: "idx-managed-library-root-path", + table: "managed-library-root", + column: "path", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "managed-file-record"); + + migrationBuilder.DropTable( + name: "managed-library-root"); + } + } +} diff --git a/Avalonia-EFCore/Migrations/SQLite/SqliteAppDataContextModelSnapshot.cs b/Avalonia-EFCore/Migrations/SQLite/SqliteAppDataContextModelSnapshot.cs index 6438beb..b53387b 100644 --- a/Avalonia-EFCore/Migrations/SQLite/SqliteAppDataContextModelSnapshot.cs +++ b/Avalonia-EFCore/Migrations/SQLite/SqliteAppDataContextModelSnapshot.cs @@ -77,6 +77,163 @@ namespace Avalonia_EFCore.Migrations.SQLite }); }); + modelBuilder.Entity("Avalonia_EFCore.Models.ManagedFileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("AbsolutePath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT") + .HasColumnName("absolute-path"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("content-type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at"); + + b.Property("Exists") + .HasColumnType("INTEGER") + .HasColumnName("exists"); + + b.Property("Extension") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasColumnName("extension"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("TEXT") + .HasColumnName("file-name"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT") + .HasColumnName("last-seen-at"); + + b.Property("LastWriteTimeUtc") + .HasColumnType("TEXT") + .HasColumnName("last-write-time-utc"); + + b.Property("LibraryRootId") + .HasColumnType("INTEGER") + .HasColumnName("library-root-id"); + + b.Property("MediaType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("media-type"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT") + .HasColumnName("relative-path"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER") + .HasColumnName("size-bytes"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated-at"); + + b.HasKey("Id") + .HasName("pk-managed-file-record"); + + b.HasIndex("AbsolutePath") + .IsUnique() + .HasDatabaseName("idx-managed-file-record-absolute-path"); + + b.HasIndex("LibraryRootId") + .HasDatabaseName("idx-managed-file-record-root-id"); + + b.HasIndex("MediaType", "Exists") + .HasDatabaseName("idx-managed-file-record-media-type-exists"); + + b.ToTable("managed-file-record", t => + { + t.HasComment("文件库文件记录"); + }); + }); + + modelBuilder.Entity("Avalonia_EFCore.Models.ManagedLibraryRoot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("display-name"); + + b.Property("IsAvailable") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("is-available"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is-enabled"); + + b.Property("LastScanCompletedAt") + .HasColumnType("TEXT") + .HasColumnName("last-scan-completed-at"); + + b.Property("LastScanError") + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("last-scan-error"); + + b.Property("LastScanStartedAt") + .HasColumnType("TEXT") + .HasColumnName("last-scan-started-at"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("ScanIntervalMinutes") + .HasColumnType("INTEGER") + .HasColumnName("scan-interval-minutes"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated-at"); + + b.HasKey("Id") + .HasName("pk-managed-library-root"); + + b.HasIndex("Path") + .IsUnique() + .HasDatabaseName("idx-managed-library-root-path"); + + b.ToTable("managed-library-root", t => + { + t.HasComment("文件库根目录"); + }); + }); + modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b => { b.Property("Id") @@ -170,6 +327,22 @@ namespace Avalonia_EFCore.Migrations.SQLite t.HasComment("天气预报数据实体"); }); }); + + modelBuilder.Entity("Avalonia_EFCore.Models.ManagedFileRecord", b => + { + b.HasOne("Avalonia_EFCore.Models.ManagedLibraryRoot", "LibraryRoot") + .WithMany("Files") + .HasForeignKey("LibraryRootId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LibraryRoot"); + }); + + modelBuilder.Entity("Avalonia_EFCore.Models.ManagedLibraryRoot", b => + { + b.Navigation("Files"); + }); #pragma warning restore 612, 618 } } diff --git a/Avalonia-EFCore/Models/ManagedFileRecord.cs b/Avalonia-EFCore/Models/ManagedFileRecord.cs new file mode 100644 index 0000000..148d419 --- /dev/null +++ b/Avalonia-EFCore/Models/ManagedFileRecord.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Avalonia_EFCore.Models +{ + /// + /// 扫描入库的可在线查看文件。 + /// + [Comment("文件库文件记录")] + [Table("managed-file-record")] + public class ManagedFileRecord + { + /// 主键 ID。 + [Key] + [Column("id")] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + + /// 所属根目录 ID。 + [Column("library-root-id")] + public int LibraryRootId { get; set; } + + /// 文件名。 + [Column("file-name")] + [MaxLength(260)] + public string FileName { get; set; } = string.Empty; + + /// 相对根目录路径。 + [Column("relative-path")] + [MaxLength(1024)] + public string RelativePath { get; set; } = string.Empty; + + /// 服务器本机绝对路径。 + [Column("absolute-path")] + [MaxLength(2048)] + public string AbsolutePath { get; set; } = string.Empty; + + /// 扩展名,小写并包含点。 + [Column("extension")] + [MaxLength(32)] + public string Extension { get; set; } = string.Empty; + + /// 文件大小,字节。 + [Column("size-bytes")] + public long SizeBytes { get; set; } + + /// 文件最后修改时间 UTC。 + [Column("last-write-time-utc")] + public DateTime LastWriteTimeUtc { get; set; } + + /// 媒体类型:text、video、audio。 + [Column("media-type")] + [MaxLength(20)] + public string MediaType { get; set; } = string.Empty; + + /// MIME 类型。 + [Column("content-type")] + [MaxLength(100)] + public string ContentType { get; set; } = "application/octet-stream"; + + /// 文件是否仍存在。 + [Column("exists")] + public bool Exists { get; set; } = true; + + /// 最近扫描时间。 + [Column("last-seen-at")] + public DateTime LastSeenAt { get; set; } = DateTime.UtcNow; + + /// 创建时间。 + [Column("created-at")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// 更新时间。 + [Column("updated-at")] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + /// 所属根目录。 + public ManagedLibraryRoot? LibraryRoot { get; set; } + } +} diff --git a/Avalonia-EFCore/Models/ManagedLibraryRoot.cs b/Avalonia-EFCore/Models/ManagedLibraryRoot.cs new file mode 100644 index 0000000..899dc4e --- /dev/null +++ b/Avalonia-EFCore/Models/ManagedLibraryRoot.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Avalonia_EFCore.Models +{ + /// + /// 管理端添加的文件库根目录或磁盘。 + /// + [Comment("文件库根目录")] + [Table("managed-library-root")] + public class ManagedLibraryRoot + { + /// 主键 ID。 + [Key] + [Column("id")] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + + /// 服务器本机绝对路径。 + [Column("path")] + [MaxLength(1024)] + public string Path { get; set; } = string.Empty; + + /// 显示名称。 + [Column("display-name")] + [MaxLength(200)] + public string DisplayName { get; set; } = string.Empty; + + /// 是否启用扫描。 + [Column("is-enabled")] + public bool IsEnabled { get; set; } = true; + + /// 目录最近一次扫描是否可用。 + [Column("is-available")] + public bool IsAvailable { get; set; } = true; + + /// 扫描间隔分钟数。 + [Column("scan-interval-minutes")] + public int ScanIntervalMinutes { get; set; } = 5; + + /// 最近扫描开始时间。 + [Column("last-scan-started-at")] + public DateTime? LastScanStartedAt { get; set; } + + /// 最近扫描完成时间。 + [Column("last-scan-completed-at")] + public DateTime? LastScanCompletedAt { get; set; } + + /// 最近扫描错误。 + [Column("last-scan-error")] + [MaxLength(2000)] + public string? LastScanError { get; set; } + + /// 创建时间。 + [Column("created-at")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// 更新时间。 + [Column("updated-at")] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + /// 文件记录。 + public List Files { get; set; } = new(); + } +} diff --git a/Avalonia-Services/Endpoints/AppEndpoints.cs b/Avalonia-Services/Endpoints/AppEndpoints.cs index f2e9dee..060085e 100644 --- a/Avalonia-Services/Endpoints/AppEndpoints.cs +++ b/Avalonia-Services/Endpoints/AppEndpoints.cs @@ -3,6 +3,7 @@ using Avalonia_EFCore.Database; using Avalonia_EFCore.Models; using Avalonia_Services.Core; using Avalonia_Services.Services; +using Avalonia_Services.Services.FileLibrary; using Microsoft.EntityFrameworkCore; namespace Avalonia_Services.Endpoints @@ -47,6 +48,46 @@ namespace Avalonia_Services.Endpoints endpoints.MapPost("api/processData", ProcessDataAsync) .WithName("ProcessData"); + endpoints.MapGet("api/library/drives", (service, ctx) => service.GetDrivesAsync(ctx)) + .WithOpenApi("FileLibrary", "查询服务器磁盘。") + .WithName("GetLibraryDrives"); + + endpoints.MapGet("api/library/directories", (service, ctx) => service.GetDirectoriesAsync(ctx)) + .WithOpenApi("FileLibrary", "查询服务器目录。") + .WithName("GetLibraryDirectories"); + + endpoints.MapGet("api/library/roots", (service, ctx) => service.GetRootsAsync(ctx)) + .WithOpenApi("FileLibrary", "查询文件库目录。") + .WithName("GetLibraryRoots"); + + endpoints.MapPost("api/library/roots", (service, ctx) => service.AddRootAsync(ctx)) + .WithOpenApi("FileLibrary", "添加文件库目录。") + .WithName("AddLibraryRoot"); + + endpoints.MapPost("api/library/roots/enabled", (service, ctx) => service.SetRootEnabledAsync(ctx)) + .WithOpenApi("FileLibrary", "启用或禁用文件库目录。") + .WithName("SetLibraryRootEnabled"); + + endpoints.MapPost("api/library/roots/delete", (service, ctx) => service.DeleteRootAsync(ctx)) + .WithOpenApi("FileLibrary", "删除文件库目录。") + .WithName("DeleteLibraryRoot"); + + endpoints.MapPost("api/library/roots/scan", (service, ctx) => service.ScanRootAsync(ctx)) + .WithOpenApi("FileLibrary", "立即扫描文件库目录。") + .WithName("ScanLibraryRoot"); + + endpoints.MapGet("api/files", (service, ctx) => service.SearchFilesAsync(ctx)) + .WithOpenApi("FileLibrary", "分页查询已扫描文件。") + .WithName("SearchFiles"); + + endpoints.MapGet("api/files/detail", (service, ctx) => service.GetFileAsync(ctx)) + .WithOpenApi("FileLibrary", "查询文件详情。") + .WithName("GetFileDetail"); + + endpoints.MapGet("api/files/text", (service, ctx) => service.GetTextPreviewAsync(ctx)) + .WithOpenApi("FileLibrary", "预览文本文件。") + .WithName("GetTextPreview"); + // ---- 需要鉴权的端点示例 ---- // endpoints.MapGet("api/admin/dashboard", AdminDashboardAsync) // .WithName("AdminDashboard") diff --git a/Avalonia-Services/Services/FileLibrary/FileLibraryContracts.cs b/Avalonia-Services/Services/FileLibrary/FileLibraryContracts.cs new file mode 100644 index 0000000..a0047e8 --- /dev/null +++ b/Avalonia-Services/Services/FileLibrary/FileLibraryContracts.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Serialization; + +namespace Avalonia_Services.Services.FileLibrary +{ + public sealed record AddLibraryRootRequest( + [property: JsonPropertyName("path")] string? Path, + [property: JsonPropertyName("displayName")] string? DisplayName = null, + [property: JsonPropertyName("scanIntervalMinutes")] int? ScanIntervalMinutes = null); + + public sealed record UpdateLibraryRootRequest( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("isEnabled")] bool IsEnabled); + + public sealed record ScanLibraryRootRequest( + [property: JsonPropertyName("id")] int Id); + + public sealed record DeleteLibraryRootRequest( + [property: JsonPropertyName("id")] int Id); + + public sealed record DriveDto( + string Name, + string DisplayName, + string RootDirectory, + string DriveType, + long? TotalSize, + long? AvailableFreeSpace, + bool IsReady); + + public sealed record DirectoryDto( + string Name, + string FullPath); + + public sealed record LibraryRootDto( + int Id, + string Path, + string DisplayName, + bool IsEnabled, + bool IsAvailable, + int ScanIntervalMinutes, + DateTime? LastScanStartedAt, + DateTime? LastScanCompletedAt, + string? LastScanError, + int FileCount); + + public sealed record FileRecordDto( + int Id, + int LibraryRootId, + string FileName, + string RelativePath, + string Extension, + long SizeBytes, + DateTime LastWriteTimeUtc, + string MediaType, + string ContentType, + string StreamUrl, + string? TextUrl, + bool BrowserPlayable); + + public sealed record TextPreviewDto( + int Id, + string FileName, + string Content, + bool Truncated); +} diff --git a/Avalonia-Services/Services/FileLibrary/FileLibraryEndpointService.cs b/Avalonia-Services/Services/FileLibrary/FileLibraryEndpointService.cs new file mode 100644 index 0000000..a1c6539 --- /dev/null +++ b/Avalonia-Services/Services/FileLibrary/FileLibraryEndpointService.cs @@ -0,0 +1,99 @@ +using Avalonia_Common.Core; +using Avalonia_Services.Core; +using System.Text.Json; + +namespace Avalonia_Services.Services.FileLibrary +{ + public sealed class FileLibraryEndpointService(IFileLibraryService fileLibrary) : IFileLibraryEndpointService + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + public async Task GetDrivesAsync(ServiceEndpointContext ctx) + { + return ResponseHelper.Ok(await fileLibrary.GetDrivesAsync()); + } + + public async Task GetDirectoriesAsync(ServiceEndpointContext ctx) + { + var path = ctx.Query.GetValueOrDefault("path"); + return ResponseHelper.Ok(await fileLibrary.GetDirectoriesAsync(path)); + } + + public async Task GetRootsAsync(ServiceEndpointContext ctx) + { + return ResponseHelper.Ok(await fileLibrary.GetRootsAsync()); + } + + public async Task AddRootAsync(ServiceEndpointContext ctx) + { + var request = ReadBody(ctx); + return ResponseHelper.Ok(await fileLibrary.AddRootAsync(request), "文件库目录已添加并完成扫描。"); + } + + public async Task SetRootEnabledAsync(ServiceEndpointContext ctx) + { + var request = ReadBody(ctx); + return ResponseHelper.Ok(await fileLibrary.SetRootEnabledAsync(request), "文件库目录状态已更新。"); + } + + public async Task DeleteRootAsync(ServiceEndpointContext ctx) + { + var request = ReadBody(ctx); + await fileLibrary.DeleteRootAsync(request); + return ResponseHelper.Succeed("文件库目录已删除。"); + } + + public async Task ScanRootAsync(ServiceEndpointContext ctx) + { + var request = ReadBody(ctx); + return ResponseHelper.Ok(await fileLibrary.ScanRootAsync(request.Id), "文件库目录扫描完成。"); + } + + public async Task SearchFilesAsync(ServiceEndpointContext ctx) + { + return await fileLibrary.SearchFilesAsync(ctx); + } + + public async Task GetFileAsync(ServiceEndpointContext ctx) + { + var id = ReadId(ctx); + var file = await fileLibrary.GetFileAsync(id); + return file is null + ? ResponseHelper.Failure(404, "文件不存在或尚未扫描入库。") + : ResponseHelper.Ok(file); + } + + public async Task GetTextPreviewAsync(ServiceEndpointContext ctx) + { + var id = ReadId(ctx); + var preview = await fileLibrary.GetTextPreviewAsync(id); + return preview is null + ? ResponseHelper.Failure(404, "文本文件不存在或无法预览。") + : ResponseHelper.Ok(preview); + } + + private static T ReadBody(ServiceEndpointContext ctx) + { + if (string.IsNullOrWhiteSpace(ctx.Body)) + { + throw new InvalidOperationException("请求体不能为空。"); + } + + var body = JsonSerializer.Deserialize(ctx.Body, JsonOptions); + return body ?? throw new InvalidOperationException("请求体格式错误。"); + } + + private static int ReadId(ServiceEndpointContext ctx) + { + if (int.TryParse(ctx.Query.GetValueOrDefault("id"), out var id) && id > 0) + { + return id; + } + + throw new InvalidOperationException("id 参数无效。"); + } + } +} diff --git a/Avalonia-Services/Services/FileLibrary/FileLibraryService.cs b/Avalonia-Services/Services/FileLibrary/FileLibraryService.cs new file mode 100644 index 0000000..ec10cbf --- /dev/null +++ b/Avalonia-Services/Services/FileLibrary/FileLibraryService.cs @@ -0,0 +1,409 @@ +using Avalonia_Common.Core; +using Avalonia_EFCore.Database; +using Avalonia_EFCore.Models; +using Avalonia_Services.Core; +using Microsoft.EntityFrameworkCore; +using System.Text; + +namespace Avalonia_Services.Services.FileLibrary +{ + public sealed class FileLibraryService(AppDataContext db) : IFileLibraryService + { + private const int DefaultScanIntervalMinutes = 5; + private const int MaxTextPreviewBytes = 1024 * 1024; + + public Task> GetDrivesAsync(CancellationToken cancellationToken = default) + { + var drives = DriveInfo.GetDrives() + .Select(drive => new DriveDto( + drive.Name, + drive.IsReady ? $"{drive.Name} ({drive.VolumeLabel})" : drive.Name, + drive.RootDirectory.FullName, + drive.DriveType.ToString(), + SafeDriveValue(drive, d => d.TotalSize), + SafeDriveValue(drive, d => d.AvailableFreeSpace), + drive.IsReady)) + .OrderBy(drive => drive.Name) + .ToList(); + + return Task.FromResult(drives); + } + + public Task> GetDirectoriesAsync(string? path, CancellationToken cancellationToken = default) + { + var normalized = NormalizeExistingDirectory(path); + var directories = Directory.EnumerateDirectories(normalized) + .Select(directory => new DirectoryInfo(directory)) + .OrderBy(directory => directory.Name) + .Select(directory => new DirectoryDto(directory.Name, directory.FullName)) + .ToList(); + + return Task.FromResult(directories); + } + + public async Task> GetRootsAsync(CancellationToken cancellationToken = default) + { + var counts = await db.ManagedFileRecords + .Where(file => file.Exists) + .GroupBy(file => file.LibraryRootId) + .Select(group => new { RootId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(item => item.RootId, item => item.Count, cancellationToken); + + var roots = await db.ManagedLibraryRoots + .OrderBy(root => root.Path) + .ToListAsync(cancellationToken); + + return roots.Select(root => ToRootDto(root, counts.GetValueOrDefault(root.Id))).ToList(); + } + + public async Task AddRootAsync(AddLibraryRootRequest request, CancellationToken cancellationToken = default) + { + var normalized = NormalizeExistingDirectory(request.Path); + var existing = await db.ManagedLibraryRoots.FirstOrDefaultAsync(root => root.Path == normalized, cancellationToken); + if (existing is not null) + { + existing.IsEnabled = true; + existing.IsAvailable = true; + existing.DisplayName = ResolveDisplayName(normalized, request.DisplayName); + existing.ScanIntervalMinutes = NormalizeInterval(request.ScanIntervalMinutes); + await db.SaveChangesAsync(cancellationToken); + return await ScanRootAsync(existing.Id, cancellationToken); + } + + var root = new ManagedLibraryRoot + { + Path = normalized, + DisplayName = ResolveDisplayName(normalized, request.DisplayName), + ScanIntervalMinutes = NormalizeInterval(request.ScanIntervalMinutes), + IsEnabled = true, + IsAvailable = true, + }; + + db.ManagedLibraryRoots.Add(root); + await db.SaveChangesAsync(cancellationToken); + + return await ScanRootAsync(root.Id, cancellationToken); + } + + public async Task SetRootEnabledAsync(UpdateLibraryRootRequest request, CancellationToken cancellationToken = default) + { + var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == request.Id, cancellationToken) + ?? throw new InvalidOperationException("文件库目录不存在。"); + + root.IsEnabled = request.IsEnabled; + await db.SaveChangesAsync(cancellationToken); + + var count = await db.ManagedFileRecords.CountAsync(file => file.LibraryRootId == root.Id && file.Exists, cancellationToken); + return ToRootDto(root, count); + } + + public async Task DeleteRootAsync(DeleteLibraryRootRequest request, CancellationToken cancellationToken = default) + { + var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == request.Id, cancellationToken) + ?? throw new InvalidOperationException("文件库目录不存在。"); + + db.ManagedLibraryRoots.Remove(root); + await db.SaveChangesAsync(cancellationToken); + } + + public async Task ScanRootAsync(int rootId, CancellationToken cancellationToken = default) + { + var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == rootId, cancellationToken) + ?? throw new InvalidOperationException("文件库目录不存在。"); + + root.LastScanStartedAt = DateTime.UtcNow; + root.LastScanError = null; + await db.SaveChangesAsync(cancellationToken); + + try + { + if (!Directory.Exists(root.Path)) + { + throw new DirectoryNotFoundException($"目录不存在:{root.Path}"); + } + + root.IsAvailable = true; + root.IsEnabled = true; + + var existing = await db.ManagedFileRecords + .Where(file => file.LibraryRootId == root.Id) + .ToDictionaryAsync(file => file.AbsolutePath, StringComparer.OrdinalIgnoreCase, cancellationToken); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in EnumerateSupportedFiles(root.Path)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var info = new FileInfo(path); + if (!info.Exists || !MediaFileTypes.TryGet(info.Extension.ToLowerInvariant(), out var mediaType, out var contentType, out _)) + { + continue; + } + + var absolutePath = info.FullName; + seen.Add(absolutePath); + + if (!existing.TryGetValue(absolutePath, out var record)) + { + record = new ManagedFileRecord + { + LibraryRootId = root.Id, + AbsolutePath = absolutePath, + }; + db.ManagedFileRecords.Add(record); + } + + record.FileName = info.Name; + record.RelativePath = Path.GetRelativePath(root.Path, absolutePath); + record.Extension = info.Extension.ToLowerInvariant(); + record.SizeBytes = info.Length; + record.LastWriteTimeUtc = info.LastWriteTimeUtc; + record.MediaType = mediaType; + record.ContentType = contentType; + record.Exists = true; + record.LastSeenAt = DateTime.UtcNow; + } + + foreach (var stale in existing.Values.Where(file => !seen.Contains(file.AbsolutePath))) + { + stale.Exists = false; + } + + root.LastScanCompletedAt = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) + { + root.IsAvailable = false; + root.IsEnabled = false; + root.LastScanError = ex.Message; + root.LastScanCompletedAt = DateTime.UtcNow; + await db.SaveChangesAsync(CancellationToken.None); + throw; + } + + var count = await db.ManagedFileRecords.CountAsync(file => file.LibraryRootId == root.Id && file.Exists, cancellationToken); + return ToRootDto(root, count); + } + + public async Task ScanDueRootsAsync(CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var roots = await db.ManagedLibraryRoots + .Where(root => root.IsEnabled && root.IsAvailable) + .ToListAsync(cancellationToken); + + foreach (var root in roots) + { + var interval = Math.Max(1, root.ScanIntervalMinutes); + var isDue = root.LastScanCompletedAt is null || root.LastScanCompletedAt.Value.AddMinutes(interval) <= now; + if (!isDue) + { + continue; + } + + try + { + await ScanRootAsync(root.Id, cancellationToken); + } + catch + { + // ScanRootAsync records the error on the root. Continue scanning other roots. + } + } + } + + public async Task> SearchFilesAsync(ServiceEndpointContext ctx, CancellationToken cancellationToken = default) + { + var page = ParseInt(ctx.Query.GetValueOrDefault("page"), 1, 1, 100000); + var pageSize = ParseInt(ctx.Query.GetValueOrDefault("pageSize"), 24, 1, 100); + var mediaType = ctx.Query.GetValueOrDefault("mediaType")?.Trim(); + var keyword = ctx.Query.GetValueOrDefault("keyword")?.Trim(); + var rootId = ParseInt(ctx.Query.GetValueOrDefault("rootId"), 0, 0, int.MaxValue); + + var query = db.ManagedFileRecords + .AsNoTracking() + .Where(file => file.Exists && file.LibraryRoot != null && file.LibraryRoot.IsAvailable); + + if (!string.IsNullOrWhiteSpace(mediaType) && !mediaType.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(file => file.MediaType == mediaType); + } + + if (rootId > 0) + { + query = query.Where(file => file.LibraryRootId == rootId); + } + + if (!string.IsNullOrWhiteSpace(keyword)) + { + query = query.Where(file => file.FileName.Contains(keyword) || file.RelativePath.Contains(keyword)); + } + + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderBy(file => file.MediaType) + .ThenBy(file => file.RelativePath) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(file => ToFileDto(file)) + .ToListAsync(cancellationToken); + + return PagedResponse.From(items, total, page, pageSize); + } + + public async Task GetFileAsync(int id, CancellationToken cancellationToken = default) + { + return await db.ManagedFileRecords + .AsNoTracking() + .Where(file => file.Id == id && file.Exists && file.LibraryRoot != null && file.LibraryRoot.IsAvailable) + .Select(file => ToFileDto(file)) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetTextPreviewAsync(int id, CancellationToken cancellationToken = default) + { + var file = await db.ManagedFileRecords + .AsNoTracking() + .Include(item => item.LibraryRoot) + .FirstOrDefaultAsync(item => + item.Id == id + && item.Exists + && item.MediaType == "text" + && item.LibraryRoot != null + && item.LibraryRoot.IsAvailable, + cancellationToken); + + if (file is null || !File.Exists(file.AbsolutePath)) + { + return null; + } + + await using var stream = File.OpenRead(file.AbsolutePath); + var limit = (int)Math.Min(stream.Length, MaxTextPreviewBytes); + var buffer = new byte[limit]; + var read = await stream.ReadAsync(buffer.AsMemory(0, limit), cancellationToken); + var content = Encoding.UTF8.GetString(buffer, 0, read); + return new TextPreviewDto(file.Id, file.FileName, content, stream.Length > MaxTextPreviewBytes); + } + + private static IEnumerable EnumerateSupportedFiles(string rootPath) + { + var pending = new Stack(); + pending.Push(rootPath); + + while (pending.Count > 0) + { + var current = pending.Pop(); + IEnumerable directories; + IEnumerable files; + + try + { + directories = Directory.EnumerateDirectories(current); + files = Directory.EnumerateFiles(current); + } + catch + { + continue; + } + + foreach (var directory in directories) + { + pending.Push(directory); + } + + foreach (var file in files) + { + if (MediaFileTypes.TryGet(Path.GetExtension(file), out _, out _, out _)) + { + yield return file; + } + } + } + } + + private static string NormalizeExistingDirectory(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new InvalidOperationException("目录路径不能为空。"); + } + + var fullPath = Path.GetFullPath(path.Trim()); + if (!Directory.Exists(fullPath)) + { + throw new DirectoryNotFoundException($"目录不存在:{fullPath}"); + } + + return new DirectoryInfo(fullPath).FullName; + } + + private static string ResolveDisplayName(string path, string? displayName) + { + if (!string.IsNullOrWhiteSpace(displayName)) + { + return displayName.Trim(); + } + + var directory = new DirectoryInfo(path); + return string.IsNullOrWhiteSpace(directory.Name) ? directory.FullName : directory.Name; + } + + private static int NormalizeInterval(int? interval) + { + return Math.Clamp(interval ?? DefaultScanIntervalMinutes, 1, 1440); + } + + private static long? SafeDriveValue(DriveInfo drive, Func selector) + { + try + { + return drive.IsReady ? selector(drive) : null; + } + catch + { + return null; + } + } + + private static LibraryRootDto ToRootDto(ManagedLibraryRoot root, int fileCount) + { + return new LibraryRootDto( + root.Id, + root.Path, + root.DisplayName, + root.IsEnabled, + root.IsAvailable, + root.ScanIntervalMinutes, + root.LastScanStartedAt, + root.LastScanCompletedAt, + root.LastScanError, + fileCount); + } + + private static FileRecordDto ToFileDto(ManagedFileRecord file) + { + return new FileRecordDto( + file.Id, + file.LibraryRootId, + file.FileName, + file.RelativePath, + file.Extension, + file.SizeBytes, + file.LastWriteTimeUtc, + file.MediaType, + file.ContentType, + $"/api/files/{file.Id}/stream", + file.MediaType == "text" ? $"/api/files/text?id={file.Id}" : null, + MediaFileTypes.IsBrowserPlayable(file.Extension)); + } + + private static int ParseInt(string? value, int fallback, int min, int max) + { + return int.TryParse(value, out var parsed) + ? Math.Clamp(parsed, min, max) + : fallback; + } + } +} diff --git a/Avalonia-Services/Services/FileLibrary/IFileLibraryEndpointService.cs b/Avalonia-Services/Services/FileLibrary/IFileLibraryEndpointService.cs new file mode 100644 index 0000000..5fc8c32 --- /dev/null +++ b/Avalonia-Services/Services/FileLibrary/IFileLibraryEndpointService.cs @@ -0,0 +1,27 @@ +using Avalonia_Services.Core; + +namespace Avalonia_Services.Services.FileLibrary +{ + public interface IFileLibraryEndpointService + { + Task GetDrivesAsync(ServiceEndpointContext ctx); + + Task GetDirectoriesAsync(ServiceEndpointContext ctx); + + Task GetRootsAsync(ServiceEndpointContext ctx); + + Task AddRootAsync(ServiceEndpointContext ctx); + + Task SetRootEnabledAsync(ServiceEndpointContext ctx); + + Task DeleteRootAsync(ServiceEndpointContext ctx); + + Task ScanRootAsync(ServiceEndpointContext ctx); + + Task SearchFilesAsync(ServiceEndpointContext ctx); + + Task GetFileAsync(ServiceEndpointContext ctx); + + Task GetTextPreviewAsync(ServiceEndpointContext ctx); + } +} diff --git a/Avalonia-Services/Services/FileLibrary/IFileLibraryService.cs b/Avalonia-Services/Services/FileLibrary/IFileLibraryService.cs new file mode 100644 index 0000000..d814356 --- /dev/null +++ b/Avalonia-Services/Services/FileLibrary/IFileLibraryService.cs @@ -0,0 +1,30 @@ +using Avalonia_Common.Core; +using Avalonia_Services.Core; + +namespace Avalonia_Services.Services.FileLibrary +{ + public interface IFileLibraryService + { + Task> GetDrivesAsync(CancellationToken cancellationToken = default); + + Task> GetDirectoriesAsync(string? path, CancellationToken cancellationToken = default); + + Task> GetRootsAsync(CancellationToken cancellationToken = default); + + Task AddRootAsync(AddLibraryRootRequest request, CancellationToken cancellationToken = default); + + Task SetRootEnabledAsync(UpdateLibraryRootRequest request, CancellationToken cancellationToken = default); + + Task DeleteRootAsync(DeleteLibraryRootRequest request, CancellationToken cancellationToken = default); + + Task ScanRootAsync(int rootId, CancellationToken cancellationToken = default); + + Task ScanDueRootsAsync(CancellationToken cancellationToken = default); + + Task> SearchFilesAsync(ServiceEndpointContext ctx, CancellationToken cancellationToken = default); + + Task GetFileAsync(int id, CancellationToken cancellationToken = default); + + Task GetTextPreviewAsync(int id, CancellationToken cancellationToken = default); + } +} diff --git a/Avalonia-Services/Services/FileLibrary/MediaFileTypes.cs b/Avalonia-Services/Services/FileLibrary/MediaFileTypes.cs new file mode 100644 index 0000000..f0cbcc2 --- /dev/null +++ b/Avalonia-Services/Services/FileLibrary/MediaFileTypes.cs @@ -0,0 +1,51 @@ +namespace Avalonia_Services.Services.FileLibrary +{ + public static class MediaFileTypes + { + private static readonly Dictionary Types = + new(StringComparer.OrdinalIgnoreCase) + { + [".txt"] = ("text", "text/plain; charset=utf-8", true), + [".log"] = ("text", "text/plain; charset=utf-8", true), + [".json"] = ("text", "application/json; charset=utf-8", true), + [".xml"] = ("text", "application/xml; charset=utf-8", true), + [".csv"] = ("text", "text/csv; charset=utf-8", true), + [".md"] = ("text", "text/markdown; charset=utf-8", true), + [".ini"] = ("text", "text/plain; charset=utf-8", true), + [".yml"] = ("text", "text/yaml; charset=utf-8", true), + [".yaml"] = ("text", "text/yaml; charset=utf-8", true), + [".mp4"] = ("video", "video/mp4", true), + [".webm"] = ("video", "video/webm", true), + [".ogg"] = ("video", "video/ogg", true), + [".ogv"] = ("video", "video/ogg", true), + [".mov"] = ("video", "video/quicktime", false), + [".mkv"] = ("video", "video/x-matroska", false), + [".mp3"] = ("audio", "audio/mpeg", true), + [".wav"] = ("audio", "audio/wav", true), + [".m4a"] = ("audio", "audio/mp4", true), + [".aac"] = ("audio", "audio/aac", true), + [".oga"] = ("audio", "audio/ogg", true), + }; + + public static bool TryGet(string extension, out string mediaType, out string contentType, out bool browserPlayable) + { + if (Types.TryGetValue(extension, out var value)) + { + mediaType = value.MediaType; + contentType = value.ContentType; + browserPlayable = value.BrowserPlayable; + return true; + } + + mediaType = string.Empty; + contentType = "application/octet-stream"; + browserPlayable = false; + return false; + } + + public static bool IsBrowserPlayable(string extension) + { + return Types.TryGetValue(extension, out var value) && value.BrowserPlayable; + } + } +} diff --git a/Avalonia-Web-VUE/src/App.vue b/Avalonia-Web-VUE/src/App.vue index 2d653b9..0a1f946 100644 --- a/Avalonia-Web-VUE/src/App.vue +++ b/Avalonia-Web-VUE/src/App.vue @@ -1,47 +1,435 @@ - - diff --git a/Avalonia-Web-VUE/src/api/http.ts b/Avalonia-Web-VUE/src/api/http.ts index 64fb725..48c1657 100644 --- a/Avalonia-Web-VUE/src/api/http.ts +++ b/Avalonia-Web-VUE/src/api/http.ts @@ -4,8 +4,20 @@ import { isWebView2 } from './env' // WebView2 自定义协议前缀 const WEBVIEW2_BASE = 'app://api/' -// 普通浏览器 HTTP API 地址,按需修改 -const HTTP_BASE = 'http://localhost:5000/api/' +// Vite 开发页走 5206 API;API 托管前端时使用同源地址。 +const isViteDevServer = window.location.port === '51552' +const HTTP_ORIGIN = isViteDevServer + ? `${window.location.protocol}//${window.location.hostname || 'localhost'}:5206` + : window.location.origin +const HTTP_BASE = `${HTTP_ORIGIN}/api/` + +export const apiOrigin = (): string => HTTP_ORIGIN + +export const apiUrl = (path: string): string => { + if (/^https?:\/\//i.test(path)) return path + const normalized = path.startsWith('/') ? path : `/${path}` + return `${isWebView2() ? '' : HTTP_ORIGIN}${normalized}` +} // ─── axios 实例 ──────────────────────────────────────────────────────────────── @@ -31,9 +43,9 @@ http.interceptors.request.use((config) => { // WebView2 桥接和 HTTP 两个环境返回结构相同,拦截器可统一处理 http.interceptors.response.use( (response) => { - const payload = response.data as { success: boolean; data?: unknown; error?: string } + const payload = response.data as { success: boolean; data?: unknown; error?: string; message?: string } if (payload?.success === false) { - return Promise.reject(new Error(payload.error ?? '请求失败')) + return Promise.reject(new Error(payload.error ?? payload.message ?? '请求失败')) } return (payload?.data ?? payload) as never }, @@ -65,11 +77,9 @@ export async function request(endpoint: string, options: RequestOpt headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) }, body: options.body !== undefined ? JSON.stringify(options.body) : undefined, }) - const payload = await res.json() as { success: boolean; data?: T; error?: string } - // eslint-disable-next-line no-debugger - debugger + const payload = await res.json() as { success: boolean; data?: T; error?: string; message?: string } if (payload?.success === false) { - throw new Error(payload.error ?? '请求失败') + throw new Error(payload.error ?? payload.message ?? '请求失败') } return (payload?.data ?? payload) as T } diff --git a/Avalonia-Web-VUE/src/api/index.ts b/Avalonia-Web-VUE/src/api/index.ts index e383fdb..7a1aa55 100644 --- a/Avalonia-Web-VUE/src/api/index.ts +++ b/Avalonia-Web-VUE/src/api/index.ts @@ -1,8 +1,93 @@ -import { request } from './http' +import { apiUrl, request } from './http' + +export type MediaType = 'all' | 'text' | 'video' | 'audio' + +export interface DriveDto { + name: string + displayName: string + rootDirectory: string + driveType: string + totalSize: number | null + availableFreeSpace: number | null + isReady: boolean +} + +export interface DirectoryDto { + name: string + fullPath: string +} + +export interface LibraryRootDto { + id: number + path: string + displayName: string + isEnabled: boolean + isAvailable: boolean + scanIntervalMinutes: number + lastScanStartedAt: string | null + lastScanCompletedAt: string | null + lastScanError: string | null + fileCount: number +} + +export interface FileRecordDto { + id: number + libraryRootId: number + fileName: string + relativePath: string + extension: string + sizeBytes: number + lastWriteTimeUtc: string + mediaType: 'text' | 'video' | 'audio' + contentType: string + streamUrl: string + textUrl: string | null + browserPlayable: boolean +} + +export interface TextPreviewDto { + id: number + fileName: string + content: string + truncated: boolean +} + +export interface PagedResponse { + items: T[] + total: number + page: number + pageSize: number + totalPages: number +} + +const qs = (params: Record) => { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== '') search.set(key, String(value)) + } + const value = search.toString() + return value ? `?${value}` : '' +} // 业务接口定义,新增接口在此处添加一行即可 export const api = { - getUser: () => request('getUser'), + getUser: () => request('getUser'), processData: (input: string) => request('processData', { method: 'POST', body: { input } }), - wData: (input: string) => request('wData', { method: 'POST', body: { input } }), + wData: (input: string) => request('wData', { method: 'POST', body: { input } }), + getDrives: () => request('library/drives'), + getDirectories: (path: string) => request(`library/directories${qs({ path })}`), + getRoots: () => request('library/roots'), + addRoot: (body: { path: string; displayName?: string; scanIntervalMinutes?: number }) => + request('library/roots', { method: 'POST', body }), + setRootEnabled: (id: number, isEnabled: boolean) => + request('library/roots/enabled', { method: 'POST', body: { id, isEnabled } }), + deleteRoot: (id: number) => + request('library/roots/delete', { method: 'POST', body: { id } }), + scanRoot: (id: number) => + request('library/roots/scan', { method: 'POST', body: { id } }), + searchFiles: (params: { page: number; pageSize: number; mediaType?: MediaType; keyword?: string; rootId?: number }) => + request>(`files${qs(params)}`), + getTextPreview: (id: number) => + request(`files/text${qs({ id })}`), + mediaUrl: (path: string) => apiUrl(path), } diff --git a/Avalonia-Web-VUE/src/assets/main.css b/Avalonia-Web-VUE/src/assets/main.css index 36fb845..88823cb 100644 --- a/Avalonia-Web-VUE/src/assets/main.css +++ b/Avalonia-Web-VUE/src/assets/main.css @@ -1,35 +1,652 @@ @import './base.css'; -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - font-weight: normal; +:root { + --page: #f5f7fb; + --panel: #ffffff; + --panel-strong: #101828; + --line: #d9e1ea; + --text: #17202c; + --muted: #667085; + --accent: #0f766e; + --accent-strong: #115e59; + --danger: #b42318; + --danger-bg: #fff2f0; + --shadow: 0 18px 45px rgba(16, 24, 40, 0.10); } -a, -.green { +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + background: var(--page); + color: var(--text); +} + +button, +input, +select { + font: inherit; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +button { + min-height: 38px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 0 13px; + color: var(--text); + background: #fff; + cursor: pointer; +} + +button:hover:not(:disabled) { + border-color: var(--accent); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +input, +select { + width: 100%; + min-height: 42px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 0 12px; + color: var(--text); + background: #fff; +} + +a { + color: inherit; text-decoration: none; - color: hsla(160, 100%, 37%, 1); - transition: 0.4s; - padding: 3px; } -@media (hover: hover) { - a:hover { - background-color: hsla(160, 100%, 37%, 0.2); +#app { + min-height: 100vh; +} + +.primary-button { + color: #fff; + border-color: var(--accent); + background: var(--accent); +} + +.secondary-button { + color: var(--text); + background: #fff; +} + +.danger-button { + color: var(--danger); +} + +.text-button { + min-height: 30px; + border: none; + padding: 0; + color: var(--accent); + background: transparent; +} + +.error-banner { + border: 1px solid #f1b8b2; + border-radius: 10px; + padding: 10px 12px; + color: var(--danger); + background: var(--danger-bg); +} + +.empty-state, +.hint, +.unsupported { + color: var(--muted); +} + +.admin-shell { + width: min(1480px, 100%); + margin: 0 auto; + padding: 28px; +} + +.admin-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 28px; + align-items: end; + border-radius: 18px; + padding: 34px; + color: #fff; + background: + linear-gradient(135deg, rgba(15, 118, 110, 0.96), rgba(16, 24, 40, 0.98)), + #101828; + box-shadow: var(--shadow); +} + +.eyebrow { + margin: 0 0 10px; + color: #a7f3d0; + font-size: 13px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.admin-hero h1 { + margin: 0; + font-size: 36px; + font-weight: 800; +} + +.admin-hero p { + max-width: 720px; + margin: 12px 0 0; + color: #d9f5ef; +} + +.admin-metrics { + display: grid; + grid-template-columns: repeat(3, 120px); + gap: 12px; +} + +.admin-metrics div { + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 14px; + padding: 16px; + background: rgba(255, 255, 255, 0.10); +} + +.admin-metrics strong { + display: block; + font-size: 28px; + font-weight: 800; +} + +.admin-metrics span { + color: #d9f5ef; + font-size: 13px; +} + +.admin-layout { + display: grid; + grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.1fr); + gap: 18px; + margin-top: 18px; +} + +.admin-card { + border: 1px solid var(--line); + border-radius: 16px; + padding: 22px; + background: var(--panel); + box-shadow: 0 10px 24px rgba(16, 24, 40, 0.05); +} + +.card-heading, +.browser-header, +.inline-form, +.root-actions, +.root-main, +.root-meta, +.mobile-header, +.filter-row, +.player-title, +.mobile-pager { + display: flex; + align-items: center; +} + +.card-heading, +.browser-header, +.mobile-header, +.player-title { + justify-content: space-between; + gap: 14px; +} + +.card-heading h2, +.drive-list h3, +.directory-list h3 { + margin: 0; + font-size: 20px; + font-weight: 800; +} + +.card-heading p { + margin: 5px 0 0; + color: var(--muted); +} + +.client-link, +.admin-link { + flex: 0 0 auto; + border: 1px solid var(--line); + border-radius: 999px; + padding: 8px 14px; + color: var(--accent-strong); + background: #fff; +} + +.field { + display: grid; + gap: 8px; + margin-top: 20px; +} + +.field span { + color: var(--muted); + font-size: 13px; +} + +.inline-form { + gap: 10px; +} + +.inline-form button { + flex: 0 0 auto; +} + +.admin-browser { + display: grid; + grid-template-columns: 0.8fr 1.2fr; + gap: 14px; + margin-top: 22px; +} + +.drive-list, +.directory-list { + display: grid; + align-content: start; + gap: 10px; + min-height: 420px; +} + +.drive-row, +.directory-row { + display: grid; + justify-items: start; + width: 100%; + min-height: 54px; + text-align: left; +} + +.drive-row span, +.directory-row { + overflow: hidden; + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; +} + +.drive-row small, +.current-path { + color: var(--muted); + font-size: 13px; +} + +.current-path { + overflow-wrap: anywhere; + margin: 0; +} + +.root-table { + display: grid; + gap: 12px; + margin-top: 20px; +} + +.root-item { + display: grid; + gap: 12px; + border: 1px solid var(--line); + border-radius: 14px; + padding: 16px; + background: #fff; +} + +.root-main { + gap: 12px; + min-width: 0; +} + +.root-main strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 800; +} + +.root-main p, +.root-error { + margin: 4px 0 0; + overflow-wrap: anywhere; + color: var(--muted); +} + +.root-error { + color: var(--danger); +} + +.root-meta { + flex-wrap: wrap; + gap: 8px; + color: var(--muted); + font-size: 13px; +} + +.root-meta span { + border-radius: 999px; + padding: 4px 9px; + background: #f2f4f7; +} + +.root-actions { + flex-wrap: wrap; + gap: 8px; +} + +.status-pill { + flex: 0 0 auto; + border-radius: 999px; + padding: 5px 10px; + font-size: 13px; + font-weight: 800; +} + +.status-pill.ok { + color: #067647; + background: #dcfae6; +} + +.status-pill.bad { + color: var(--danger); + background: var(--danger-bg); +} + +.client-shell { + width: min(860px, 100%); + margin: 0 auto; + padding: 14px; +} + +.mobile-header { + position: sticky; + top: 0; + z-index: 5; + margin: -14px -14px 12px; + padding: 16px 14px 12px; + background: rgba(245, 247, 251, 0.94); + backdrop-filter: blur(12px); +} + +.mobile-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.back-button { + border-radius: 999px; + color: var(--accent-strong); +} + +.mobile-header h1 { + margin: 0; + font-size: 28px; + font-weight: 850; +} + +.mobile-header p, +.player-title p, +.mobile-file small { + margin: 3px 0 0; + color: var(--muted); +} + +.mobile-filters, +.player-panel, +.mobile-list, +.root-picker { + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px; + background: var(--panel); +} + +.root-picker { + display: grid; + gap: 10px; +} + +.root-tile { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 12px; + width: 100%; + min-height: 76px; + padding: 13px; + text-align: left; +} + +.root-tile span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 850; +} + +.root-tile strong { + grid-row: span 2; + align-self: center; + color: var(--accent-strong); + font-size: 24px; + font-weight: 850; +} + +.root-tile small { + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +.filter-row { + gap: 8px; + margin-top: 8px; +} + +.filter-row select { + min-width: 0; +} + +.filter-row button { + flex: 0 0 auto; +} + +.player-panel { + margin-top: 12px; +} + +.player-title { + align-items: flex-start; +} + +.player-title h2 { + margin: 0; + overflow-wrap: anywhere; + font-size: 18px; + font-weight: 800; +} + +.player-title span { + flex: 0 0 auto; + border-radius: 999px; + padding: 4px 9px; + color: var(--accent-strong); + background: #ccfbef; + font-size: 12px; + font-weight: 800; +} + +.player-panel video { + width: 100%; + max-height: 54vh; + margin-top: 12px; + border-radius: 10px; + background: #111827; +} + +.player-panel audio { + width: 100%; + margin-top: 18px; +} + +.open-media-link { + display: block; + width: 100%; + margin-top: 10px; + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px 12px; + color: var(--accent-strong); + background: #f0fdfa; + text-align: center; + font-weight: 800; +} + +.player-panel pre { + overflow: auto; + max-height: 54vh; + margin: 12px 0 0; + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px; + background: #f8fafc; + color: #111827; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.mobile-list { + display: grid; + gap: 9px; + margin-top: 12px; + padding: 8px; +} + +.mobile-file { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + width: 100%; + min-height: 68px; + padding: 10px; + text-align: left; +} + +.mobile-file.active { + border-color: var(--accent); + background: #ecfdf5; +} + +.mobile-file > span:last-child { + display: grid; + min-width: 0; +} + +.mobile-file strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 800; +} + +.type-badge { + flex: 0 0 52px; + border-radius: 999px; + padding: 4px 8px; + color: #fff; + background: var(--accent-strong); + text-align: center; + font-size: 12px; + font-weight: 800; +} + +.mobile-pager { + justify-content: center; + gap: 10px; + padding: 14px 0 4px; +} + +.empty-state, +.unsupported { + margin: 22px 0; + text-align: center; +} + +@media (max-width: 1100px) { + .admin-layout, + .admin-browser { + grid-template-columns: 1fr; + } + + .admin-metrics { + grid-template-columns: repeat(3, minmax(90px, 1fr)); } } -@media (min-width: 1024px) { - body { - display: flex; - place-items: center; +@media (max-width: 780px) { + .admin-shell { + padding: 14px; } - #app { - display: grid; - grid-template-columns: 1fr 1fr; - padding: 0 2rem; + .admin-hero { + grid-template-columns: 1fr; + padding: 22px; + } + + .admin-hero h1 { + font-size: 30px; + } + + .admin-metrics { + grid-template-columns: 1fr; + } + + .inline-form, + .filter-row { + align-items: stretch; + flex-direction: column; + } + + .inline-form button, + .filter-row button { + width: 100%; + } +} + +@media (min-width: 900px) { + .client-shell { + padding-top: 24px; + } + + .mobile-header { + position: static; + margin: 0 0 14px; + border: 1px solid var(--line); + border-radius: 16px; + padding: 18px; + background: var(--panel); } } diff --git a/Avalonia-Web-VUE/vite.config.ts b/Avalonia-Web-VUE/vite.config.ts index ddceca2..496f438 100644 --- a/Avalonia-Web-VUE/vite.config.ts +++ b/Avalonia-Web-VUE/vite.config.ts @@ -1,10 +1,14 @@ -import { defineConfig } from 'vite'; -import plugin from '@vitejs/plugin-vue'; +import { defineConfig } from 'vite' +import plugin from '@vitejs/plugin-vue' // https://vitejs.dev/config/ export default defineConfig({ - plugins: [plugin()], - server: { - port: 51552, - } + plugins: [plugin()], + build: { + outDir: 'dist', + emptyOutDir: true, + }, + server: { + port: 51552, + }, }) diff --git a/app.db b/app.db new file mode 100644 index 0000000000000000000000000000000000000000..4e86411b5803e34b1e4767ce981907694f15c1ed GIT binary patch literal 4096 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 A%K!iX literal 0 HcmV?d00001 diff --git a/logs/log-20260521.txt b/logs/log-20260521.txt new file mode 100644 index 0000000..8df6fd6 --- /dev/null +++ b/logs/log-20260521.txt @@ -0,0 +1,310 @@ +2026-05-21 16:16:06.522 [INF] Avalonia-API 正在启动... +2026-05-21 16:16:06.716 [INF] 正在初始化数据库 Provider="SQLite", AppVersion=1.0.0+e3fe965f108e748a0bfa13f6f122d650d73575d9 +2026-05-21 16:16:07.218 [INF] 未检测到已应用迁移,将按当前 Provider="SQLite" 从 0 构建完整表结构 +2026-05-21 16:16:07.219 [INF] 当前已应用 0 个迁移,检测到 5 个待执行迁移: 20260514000100_InitialCreate, 20260515072045_AutoMigration_20260515152037, 20260515085847_AutoMigration_20260515165835, 20260520083230_AutoMigration_20260520163216, 20260521080213_AddFileLibrary +2026-05-21 16:16:07.518 [INF] 数据库迁移完成(5 个迁移已应用) +2026-05-21 16:16:07.537 [WRN] The WebRootPath was not found: D:\Project\FileShare\wwwroot. Static files may be unavailable. +2026-05-21 16:16:07.553 [INF] User profile is available. Using 'C:\Users\Administrator\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest. +2026-05-21 16:16:07.574 [ERR] An exception occurred while trying to decrypt the element. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) +2026-05-21 16:16:07.584 [ERR] An exception occurred while processing the key element ''. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) +2026-05-21 16:16:07.791 [ERR] An exception occurred while trying to decrypt the element. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) +2026-05-21 16:16:07.792 [ERR] An exception occurred while processing the key element ''. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) +2026-05-21 16:16:08.004 [ERR] An exception occurred while trying to decrypt the element. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) +2026-05-21 16:16:08.004 [ERR] An exception occurred while processing the key element ''. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) +2026-05-21 16:16:08.213 [ERR] An exception occurred while trying to decrypt the element. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) +2026-05-21 16:16:08.213 [ERR] An exception occurred while processing the key element ''. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) +2026-05-21 16:16:08.417 [ERR] An exception occurred while trying to decrypt the element. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) +2026-05-21 16:16:08.417 [ERR] An exception occurred while processing the key element ''. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) +2026-05-21 16:16:08.628 [ERR] An exception occurred while trying to decrypt the element. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) +2026-05-21 16:16:08.629 [ERR] An exception occurred while processing the key element ''. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) +2026-05-21 16:16:08.837 [ERR] An exception occurred while trying to decrypt the element. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) +2026-05-21 16:16:08.838 [ERR] An exception occurred while processing the key element ''. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) +2026-05-21 16:16:09.048 [ERR] An exception occurred while trying to decrypt the element. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) +2026-05-21 16:16:09.049 [ERR] An exception occurred while processing the key element ''. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) +2026-05-21 16:16:09.252 [ERR] An exception occurred while trying to decrypt the element. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) +2026-05-21 16:16:09.252 [ERR] An exception occurred while processing the key element ''. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) +2026-05-21 16:16:09.467 [ERR] An exception occurred while trying to decrypt the element. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) +2026-05-21 16:16:09.467 [ERR] An exception occurred while processing the key element ''. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) +2026-05-21 16:16:09.681 [ERR] An exception occurred while trying to decrypt the element. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) +2026-05-21 16:16:09.682 [ERR] An exception occurred while processing the key element ''. +System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) +2026-05-21 16:16:09.685 [WRN] Key {8ddb637d-22e6-47ce-8bac-51dcba504349} is ineligible to be the default key because its CreateEncryptor method failed after the maximum number of retries. +System.AggregateException: One or more errors occurred. (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) + ---> System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor() + at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor() + at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining) + --- End of inner exception stack trace --- + ---> (Inner Exception #1) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor() + at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor() + at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<--- + + ---> (Inner Exception #2) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor() + at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor() + at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<--- + + ---> (Inner Exception #3) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor() + at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor() + at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<--- + + ---> (Inner Exception #4) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor() + at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor() + at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<--- + + ---> (Inner Exception #5) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor() + at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor() + at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<--- + + ---> (Inner Exception #6) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor() + at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor() + at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<--- + + ---> (Inner Exception #7) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor() + at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor() + at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<--- + + ---> (Inner Exception #8) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor() + at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor() + at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<--- + + ---> (Inner Exception #9) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor() + at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor() + at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<--- + + ---> (Inner Exception #10) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation. + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy) + at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement) + at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor() + at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key) + at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor() + at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<--- + +2026-05-21 16:16:09.689 [INF] Creating key {ae945183-411d-4f02-bb46-18985da84c2c} with creation date 2026-05-21 08:16:07Z, activation date 2026-05-21 08:16:07Z, and expiration date 2026-08-19 08:16:07Z. +2026-05-21 16:16:09.698 [ERR] An error occurred while reading the key ring. +System.UnauthorizedAccessException: Access to the path 'C:\Users\Administrator\AppData\Local\ASP.NET\DataProtection-Keys\32a7edcd-b47a-4345-a5b4-266014323e5e.tmp' is denied. + at Microsoft.Win32.SafeHandles.SafeFileHandle.CreateFile(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options) + at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode) + at System.IO.Strategies.OSFileStreamStrategy..ctor(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode) + at System.IO.Strategies.FileStreamHelpers.ChooseStrategyCore(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode) + at System.IO.File.OpenWrite(String path) + at Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository.StoreElementCore(XElement element, String filename) + at Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository.StoreElement(XElement element, String friendlyName) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.CreateNewKey(Guid keyId, DateTimeOffset creationDate, DateTimeOffset activationDate, DateTimeOffset expirationDate) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.CreateNewKey(DateTimeOffset activationDate, DateTimeOffset expirationDate) + at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider.CreateCacheableKeyRingCore(DateTimeOffset now, IKey keyJustAdded) + at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider.b__31_1(Object utcNowState) + at System.Threading.Tasks.Task`1.InnerInvoke() + at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state) +--- End of stack trace from previous location --- + at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state) + at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread) +--- End of stack trace from previous location --- + at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider.GetKeyRingFromCompletedTaskUnsynchronized(Task`1 task, DateTime utcNow) +2026-05-21 16:16:09.700 [INF] Key ring failed to load during application startup. +System.UnauthorizedAccessException: Access to the path 'C:\Users\Administrator\AppData\Local\ASP.NET\DataProtection-Keys\32a7edcd-b47a-4345-a5b4-266014323e5e.tmp' is denied. + at Microsoft.Win32.SafeHandles.SafeFileHandle.CreateFile(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options) + at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode) + at System.IO.Strategies.OSFileStreamStrategy..ctor(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode) + at System.IO.Strategies.FileStreamHelpers.ChooseStrategyCore(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode) + at System.IO.File.OpenWrite(String path) + at Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository.StoreElementCore(XElement element, String filename) + at Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository.StoreElement(XElement element, String friendlyName) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.CreateNewKey(Guid keyId, DateTimeOffset creationDate, DateTimeOffset activationDate, DateTimeOffset expirationDate) + at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.CreateNewKey(DateTimeOffset activationDate, DateTimeOffset expirationDate) + at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider.CreateCacheableKeyRingCore(DateTimeOffset now, IKey keyJustAdded) + at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider.b__31_1(Object utcNowState) + at System.Threading.Tasks.Task`1.InnerInvoke() + at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state) +--- End of stack trace from previous location --- + at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state) + at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread) +--- End of stack trace from previous location --- + at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider.GetKeyRingFromCompletedTaskUnsynchronized(Task`1 task, DateTime utcNow) + at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider.GetCurrentKeyRingCoreNew(DateTime utcNow, Boolean forceRefresh) + at Microsoft.AspNetCore.DataProtection.Internal.DataProtectionHostedService.StartAsync(CancellationToken token) +2026-05-21 16:16:09.781 [WRN] The WebRootPath was not found: D:\Project\FileShare\wwwroot. Static files may be unavailable. +2026-05-21 16:16:09.795 [INF] Now listening on: http://localhost:5206 +2026-05-21 16:16:09.796 [INF] Application started. Press Ctrl+C to shut down. +2026-05-21 16:16:09.796 [INF] Hosting environment: Production +2026-05-21 16:16:09.797 [INF] Content root path: D:\Project\FileShare