diff --git a/.gitignore b/.gitignore index 4425d92..1831e48 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ /.vs /bin /obj +/.claude diff --git a/Avalonia-API/Authentication/ApiAuthEndpointService.cs b/Avalonia-API/Authentication/ApiAuthEndpointService.cs index 8c97530..ce714aa 100644 --- a/Avalonia-API/Authentication/ApiAuthEndpointService.cs +++ b/Avalonia-API/Authentication/ApiAuthEndpointService.cs @@ -4,7 +4,6 @@ using Avalonia_EFCore.Models; using Avalonia_Services.Core; using Avalonia_Services.Services.AuthService; using Microsoft.EntityFrameworkCore; -using System.Text.Json; namespace Avalonia_API.Authentication { @@ -17,21 +16,15 @@ namespace Avalonia_API.Authentication JwtTokenService jwtTokenService, RefreshTokenService refreshTokenService) : IApiAuthEndpointService { - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true, - }; - /// /// 处理用户登录请求。根据账号(邮箱或用户名)查找或创建用户, /// 生成 JWT Access Token 和 Refresh Token 并返回。 /// /// 服务端点上下文,包含请求体、请求头等信息。 /// 包含 AccessToken、RefreshToken 及过期时间的认证响应。 - public async Task LoginAsync(ServiceEndpointContext ctx) + public async Task LoginAsync(ApiLoginRequest request, ServiceEndpointContext ctx) { - var request = Deserialize(ctx.Body); - if (string.IsNullOrWhiteSpace(request?.Account)) + if (string.IsNullOrWhiteSpace(request.Account)) { ctx.StatusCode = 400; return ResponseHelper.Failure(400, "账号不能为空"); @@ -72,11 +65,10 @@ namespace Avalonia_API.Authentication /// /// 服务端点上下文,包含请求体中的 RefreshToken。 /// 新的 Token 对;若 Refresh Token 无效则返回 401 错误。 - public async Task RefreshAsync(ServiceEndpointContext ctx) + public async Task RefreshAsync(ApiRefreshTokenRequest request, ServiceEndpointContext ctx) { - var request = Deserialize(ctx.Body); var rotated = await refreshTokenService.RotateAsync( - request?.RefreshToken, + request.RefreshToken, ctx.GetHeader("User-Agent"), GetRemoteIpAddress(ctx)); @@ -109,26 +101,12 @@ namespace Avalonia_API.Authentication /// /// 服务端点上下文,包含请求体中的 RefreshToken。 /// 登出成功的响应。 - public async Task LogoutAsync(ServiceEndpointContext ctx) + public async Task LogoutAsync(ApiLogoutRequest request, ServiceEndpointContext ctx) { - var request = Deserialize(ctx.Body); - await refreshTokenService.RevokeAsync(request?.RefreshToken); + await refreshTokenService.RevokeAsync(request.RefreshToken); return ResponseHelper.Succeed("退出成功"); } - /// - /// 将 JSON 请求体反序列化为指定类型。 - /// - /// 目标类型。 - /// JSON 请求体字符串,可为空。 - /// 反序列化后的对象;若 body 为空则返回默认值。 - private static T? Deserialize(string? body) - { - return string.IsNullOrWhiteSpace(body) - ? default - : JsonSerializer.Deserialize(body, JsonOptions); - } - /// /// 从上下文的 Items 中提取 ASP.NET Core HttpContext,并获取客户端远程 IP 地址。 /// diff --git a/Avalonia-API/Avalonia-API.csproj b/Avalonia-API/Avalonia-API.csproj index 59c4954..96de241 100644 --- a/Avalonia-API/Avalonia-API.csproj +++ b/Avalonia-API/Avalonia-API.csproj @@ -41,10 +41,7 @@ - + diff --git a/Avalonia-API/Configuration/ServicesConfiguration.cs b/Avalonia-API/Configuration/ServicesConfiguration.cs index 34044c9..5245402 100644 --- a/Avalonia-API/Configuration/ServicesConfiguration.cs +++ b/Avalonia-API/Configuration/ServicesConfiguration.cs @@ -6,6 +6,7 @@ using Avalonia_Services.Endpoints; using Avalonia_Services.Services; using Avalonia_Services.Services.AuthService; using Avalonia_Services.Services.FileLibrary; +using Avalonia_Services.Services.QrCode; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; @@ -41,6 +42,8 @@ namespace Avalonia_API.Configuration services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddHostedService(); services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "data-protection-keys"))); diff --git a/Avalonia-API/Extensions/FileStreamEndpointExtensions.cs b/Avalonia-API/Extensions/FileStreamEndpointExtensions.cs index 2e99e4a..026512a 100644 --- a/Avalonia-API/Extensions/FileStreamEndpointExtensions.cs +++ b/Avalonia-API/Extensions/FileStreamEndpointExtensions.cs @@ -1,45 +1,47 @@ -using Avalonia_EFCore.Database; -using Microsoft.EntityFrameworkCore; +using Avalonia_Services.Services.FileLibrary; namespace Avalonia_API.Extensions { + /// + /// API-only raw file stream endpoints used by browser media elements. + /// public static class FileStreamEndpointExtensions { + /// + /// Map the media URL emitted by . + /// 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); + app.MapMethods( + "/api/files/{id:int}/stream", + ["GET", "HEAD"], + async (int id, IFileStreamService fileStreamService, HttpContext httpContext) => + { + var fileResponse = await fileStreamService.GetFileStreamAsync(id); + if (fileResponse is null) + { + return Results.NotFound(); + } - if (file is null || !System.IO.File.Exists(file.AbsolutePath)) - { - return Results.NotFound(); - } + var stream = System.IO.File.Open( + fileResponse.FilePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite); - 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"; + httpContext.Response.Headers.ContentDisposition = + $"inline; filename=\"{Uri.EscapeDataString(fileResponse.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 Results.File( + stream, + contentType: fileResponse.ContentType, + lastModified: fileResponse.LastModified, + enableRangeProcessing: true); + }) + .WithName("StreamManagedFileById") + .WithTags("FileLibrary"); return app; } diff --git a/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs b/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs index 76889fc..774a8e0 100644 --- a/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs +++ b/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs @@ -81,7 +81,8 @@ namespace Avalonia_API.Extensions routeHandlerBuilder.WithSummary(endpoint.OpenApiSummary); } - if (endpoint.OpenApiRequestType is not null) + if (endpoint.OpenApiRequestType is not null + && endpoint.HttpMethod is "POST" or "PUT") { routeHandlerBuilder.Accepts(endpoint.OpenApiRequestType, "application/json"); } @@ -146,6 +147,23 @@ namespace Avalonia_API.Extensions httpContext.Response.Headers[kvp.Key] = kvp.Value; } + if (result is FileStreamResponse fileResponse) + { + if (!System.IO.File.Exists(fileResponse.FilePath)) + return Results.NotFound(); + + var stream = System.IO.File.Open(fileResponse.FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + httpContext.Response.Headers.ContentDisposition = $"inline; filename=\"{Uri.EscapeDataString(fileResponse.FileName)}\""; + httpContext.Response.Headers.AcceptRanges = "bytes"; + httpContext.Response.Headers.CacheControl = "public, max-age=3600"; + + return Results.File( + stream, + contentType: fileResponse.ContentType, + lastModified: fileResponse.LastModified, + enableRangeProcessing: true); + } + return result is not null ? Results.Json(result) : Results.Ok(); }; } @@ -175,6 +193,14 @@ namespace Avalonia_API.Extensions ctx.Query[query.Key] = query.Value.ToString(); } + foreach (var routeValue in httpContext.Request.RouteValues) + { + if (routeValue.Value is not null) + { + ctx.RouteValues[routeValue.Key] = routeValue.Value.ToString() ?? string.Empty; + } + } + if (httpContext.Request.ContentLength > 0) { using var reader = new StreamReader(httpContext.Request.Body); diff --git a/Avalonia-Common/Core/ApiResponse.cs b/Avalonia-Common/Core/ApiResponse.cs index 404e9c8..8fa4194 100644 --- a/Avalonia-Common/Core/ApiResponse.cs +++ b/Avalonia-Common/Core/ApiResponse.cs @@ -2,12 +2,24 @@ using System.Text.Json.Serialization; namespace Avalonia_Common.Core { + /// + /// 统一端点响应契约。 + /// + public interface IApiResponse + { + /// 是否成功。 + bool Success { get; } + + /// 业务状态码。 + int Code { get; } + } + /// /// 统一 API 返回格式。 /// 所有接口的返回都包装为此格式,确保前端收到一致的数据结构。 /// /// 业务数据类型 - public class ApiResponse + public class ApiResponse : IApiResponse { /// 是否成功 [JsonPropertyName("success")] @@ -113,7 +125,7 @@ namespace Avalonia_Common.Core /// /// 分页返回格式 /// - public class PagedResponse + public class PagedResponse : IApiResponse { /// /// 获取或设置操作是否成功。 diff --git a/Avalonia-PC/Authentication/PcAuthEndpointService.cs b/Avalonia-PC/Authentication/PcAuthEndpointService.cs index 270b01b..7152333 100644 --- a/Avalonia-PC/Authentication/PcAuthEndpointService.cs +++ b/Avalonia-PC/Authentication/PcAuthEndpointService.cs @@ -3,7 +3,6 @@ using Avalonia_Common.Core; using Avalonia_Services.Core; using Avalonia_Services.Services.AuthService; using System; -using System.Text.Json; using System.Threading.Tasks; namespace Avalonia_PC.Authentication @@ -14,16 +13,10 @@ namespace Avalonia_PC.Authentication /// public sealed class PcAuthEndpointService(PcGlobalTokenService tokenService) : IPcAuthEndpointService { - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true, - }; - /// - public async Task AuthorizeAsync(ServiceEndpointContext ctx) + public async Task AuthorizeAsync(PcAuthorizeRequest request, ServiceEndpointContext ctx) { - var request = Deserialize(ctx.Body); - var token = await tokenService.AuthorizeAsync(request?.AuthorizationCode); + var token = await tokenService.AuthorizeAsync(request.AuthorizationCode); if (token is null) { ctx.StatusCode = 401; @@ -34,10 +27,9 @@ namespace Avalonia_PC.Authentication } /// - public async Task RefreshAsync(ServiceEndpointContext ctx) + public async Task RefreshAsync(PcRefreshRequest request, ServiceEndpointContext ctx) { - var request = Deserialize(ctx.Body); - var token = request?.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization")); + var token = request.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization")); var refreshed = await tokenService.RefreshAsync(token); if (refreshed is null) { @@ -49,25 +41,11 @@ namespace Avalonia_PC.Authentication } /// - public Task LogoutAsync(ServiceEndpointContext ctx) + public Task LogoutAsync(PcLogoutRequest request, ServiceEndpointContext ctx) { - var request = Deserialize(ctx.Body); - var token = request?.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization")); + var token = request.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization")); tokenService.Logout(token); - return Task.FromResult(ResponseHelper.Succeed("退出成功")); - } - - /// - /// 将 JSON 请求体反序列化为指定类型。 - /// - /// 目标类型。 - /// JSON 请求体字符串,可为空。 - /// 反序列化后的对象;若 body 为空则返回默认值。 - private static T? Deserialize(string? body) - { - return string.IsNullOrWhiteSpace(body) - ? default - : JsonSerializer.Deserialize(body, JsonOptions); + return Task.FromResult(ResponseHelper.Succeed("退出成功")); } /// diff --git a/Avalonia-PC/Program.cs b/Avalonia-PC/Program.cs index 2283dd5..2781d4c 100644 --- a/Avalonia-PC/Program.cs +++ b/Avalonia-PC/Program.cs @@ -8,6 +8,7 @@ using Avalonia_Services.Core; using Avalonia_Services.Endpoints; using Avalonia_Services.Services; using Avalonia_Services.Services.AuthService; +using Avalonia_Services.Services.FileLibrary; using Microsoft.Extensions.DependencyInjection; using Serilog; using System; @@ -70,6 +71,9 @@ namespace Avalonia_PC services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // ---- 端点注册 ---- var endpointBuilder = new ServiceEndpointBuilder(); diff --git a/Avalonia-PC/Views/MainWindow.axaml.cs b/Avalonia-PC/Views/MainWindow.axaml.cs index 88cd6dd..5d388b6 100644 --- a/Avalonia-PC/Views/MainWindow.axaml.cs +++ b/Avalonia-PC/Views/MainWindow.axaml.cs @@ -9,6 +9,8 @@ using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Avalonia_Services.Services.FileLibrary; +using Microsoft.Extensions.DependencyInjection; namespace Avalonia_PC.Views { @@ -207,21 +209,27 @@ namespace Avalonia_PC.Views return; } + var localHtmlPath = GetConfiguredLocalStartupPath(); + var localRoot = !string.IsNullOrWhiteSpace(localHtmlPath) + ? Path.GetDirectoryName(localHtmlPath) + : null; + if (!string.IsNullOrWhiteSpace(localRoot)) + { + await EnsureLocalHttpServerStartedAsync(localRoot); + } + var onlineUrl = GetConfiguredOnlineStartupUrl(); if (onlineUrl is not null) { - StopLocalHttpServer(); _webView.Source = onlineUrl; return; } - var localHtmlPath = GetConfiguredLocalStartupPath(); if (string.IsNullOrWhiteSpace(localHtmlPath) || !File.Exists(localHtmlPath)) { return; } - var localRoot = Path.GetDirectoryName(localHtmlPath); if (string.IsNullOrWhiteSpace(localRoot)) { return; @@ -248,6 +256,11 @@ namespace Avalonia_PC.Views } await _webView.InvokeScript(BridgeScript); + if (!string.IsNullOrWhiteSpace(_localHttpBaseUrl)) + { + var mediaOriginLiteral = JsonSerializer.Serialize(_localHttpBaseUrl.TrimEnd('/')); + await _webView.InvokeScript($"window.__pcMediaOrigin = {mediaOriginLiteral}"); + } } #endregion @@ -514,7 +527,7 @@ namespace Avalonia_PC.Views /// /// 本地静态服务主循环,持续接收并分发请求。 /// - private static async Task RunLocalHttpServerLoopAsync(HttpListener listener, CancellationToken cancellationToken, string wwwRoot) + private async Task RunLocalHttpServerLoopAsync(HttpListener listener, CancellationToken cancellationToken, string wwwRoot) { try { @@ -532,10 +545,15 @@ namespace Avalonia_PC.Views /// /// 处理本地静态资源请求并返回文件内容。 /// - private static async Task HandleLocalHttpRequest(HttpListenerContext context, string wwwRoot) + private async Task HandleLocalHttpRequest(HttpListenerContext context, string wwwRoot) { try { + if (await TryHandleLocalMediaStreamAsync(context)) + { + return; + } + var relativePath = context.Request.Url?.AbsolutePath.TrimStart('/') ?? string.Empty; if (string.IsNullOrWhiteSpace(relativePath)) { @@ -572,6 +590,170 @@ namespace Avalonia_PC.Views } } + /// + /// Handle media element requests using the same stream path as Avalonia-API. + /// + private async Task TryHandleLocalMediaStreamAsync(HttpListenerContext context) + { + var request = context.Request; + var response = context.Response; + var segments = (request.Url?.AbsolutePath ?? string.Empty) + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (segments.Length != 4 + || !string.Equals(segments[0], "api", StringComparison.OrdinalIgnoreCase) + || !string.Equals(segments[1], "files", StringComparison.OrdinalIgnoreCase) + || !string.Equals(segments[3], "stream", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + AddLocalMediaHeaders(response); + + if (string.Equals(request.HttpMethod, "OPTIONS", StringComparison.OrdinalIgnoreCase)) + { + response.StatusCode = (int)HttpStatusCode.NoContent; + response.Close(); + return true; + } + + if (!string.Equals(request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase) + && !string.Equals(request.HttpMethod, "HEAD", StringComparison.OrdinalIgnoreCase)) + { + response.StatusCode = (int)HttpStatusCode.MethodNotAllowed; + response.Close(); + return true; + } + + if (!int.TryParse(segments[2], out var id) || id <= 0) + { + response.StatusCode = (int)HttpStatusCode.NotFound; + response.Close(); + return true; + } + + using var scope = _services.CreateScope(); + var fileStreamService = scope.ServiceProvider.GetService(); + var fileResponse = fileStreamService is null ? null : await fileStreamService.GetFileStreamAsync(id); + if (fileResponse is null || !File.Exists(fileResponse.FilePath)) + { + response.StatusCode = (int)HttpStatusCode.NotFound; + response.Close(); + return true; + } + + var length = new FileInfo(fileResponse.FilePath).Length; + var start = 0L; + var end = length > 0 ? length - 1 : 0; + var isRange = TryParseByteRange(request.Headers["Range"], length, out start, out end); + if (!string.IsNullOrWhiteSpace(request.Headers["Range"]) && !isRange) + { + response.StatusCode = (int)HttpStatusCode.RequestedRangeNotSatisfiable; + response.Headers["Content-Range"] = $"bytes */{length}"; + response.Close(); + return true; + } + + var contentLength = length == 0 ? 0 : end - start + 1; + response.StatusCode = isRange + ? (int)HttpStatusCode.PartialContent + : (int)HttpStatusCode.OK; + response.ContentType = fileResponse.ContentType; + response.ContentLength64 = contentLength; + response.Headers["Accept-Ranges"] = "bytes"; + response.Headers["Cache-Control"] = "public, max-age=3600"; + response.Headers["Content-Disposition"] = + $"inline; filename=\"{Uri.EscapeDataString(fileResponse.FileName)}\""; + response.Headers["Last-Modified"] = fileResponse.LastModified.ToUniversalTime().ToString("R"); + if (isRange) + { + response.Headers["Content-Range"] = $"bytes {start}-{end}/{length}"; + } + + if (string.Equals(request.HttpMethod, "HEAD", StringComparison.OrdinalIgnoreCase) + || contentLength == 0) + { + response.Close(); + return true; + } + + await using var input = File.Open(fileResponse.FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + input.Seek(start, SeekOrigin.Begin); + await CopyRangeAsync(input, response.OutputStream, contentLength); + response.OutputStream.Close(); + return true; + } + + private static void AddLocalMediaHeaders(HttpListenerResponse response) + { + response.Headers["Access-Control-Allow-Origin"] = "*"; + response.Headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS"; + response.Headers["Access-Control-Allow-Headers"] = "Range, Content-Type, Authorization"; + response.Headers["Access-Control-Expose-Headers"] = "Accept-Ranges, Content-Length, Content-Range"; + } + + private static bool TryParseByteRange(string? value, long length, out long start, out long end) + { + start = 0; + end = length > 0 ? length - 1 : 0; + if (length <= 0 || string.IsNullOrWhiteSpace(value) || !value.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var range = value["bytes=".Length..].Split(',', 2)[0].Trim(); + var separatorIndex = range.IndexOf('-'); + if (separatorIndex < 0) + { + return false; + } + + var startValue = range[..separatorIndex]; + var endValue = range[(separatorIndex + 1)..]; + if (string.IsNullOrWhiteSpace(startValue)) + { + if (!long.TryParse(endValue, out var suffixLength) || suffixLength <= 0) + { + return false; + } + + start = Math.Max(0, length - suffixLength); + end = length - 1; + return true; + } + + if (!long.TryParse(startValue, out start) || start < 0 || start >= length) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(endValue) + && (!long.TryParse(endValue, out end) || end < start)) + { + return false; + } + + end = Math.Min(end, length - 1); + return true; + } + + private static async Task CopyRangeAsync(Stream input, Stream output, long bytesRemaining) + { + var buffer = new byte[64 * 1024]; + while (bytesRemaining > 0) + { + var readLength = (int)Math.Min(buffer.Length, bytesRemaining); + var read = await input.ReadAsync(buffer.AsMemory(0, readLength)); + if (read <= 0) + { + break; + } + + await output.WriteAsync(buffer.AsMemory(0, read)); + bytesRemaining -= read; + } + } + /// /// 根据后缀返回静态资源 Content-Type。 /// diff --git a/Avalonia-Services/Avalonia-Services.csproj b/Avalonia-Services/Avalonia-Services.csproj index 19a6a29..ad7c603 100644 --- a/Avalonia-Services/Avalonia-Services.csproj +++ b/Avalonia-Services/Avalonia-Services.csproj @@ -9,6 +9,7 @@ + diff --git a/Avalonia-Services/Core/FileStreamResponse.cs b/Avalonia-Services/Core/FileStreamResponse.cs new file mode 100644 index 0000000..e813c3c --- /dev/null +++ b/Avalonia-Services/Core/FileStreamResponse.cs @@ -0,0 +1,11 @@ +namespace Avalonia_Services.Core +{ + /// + /// 文件流响应 —— 管道检测到此类型时将返回原始文件而非 JSON。 + /// + public sealed record FileStreamResponse( + string FilePath, + string FileName, + string ContentType, + DateTime LastModified); +} diff --git a/Avalonia-Services/Core/ServiceEndpointCollection.cs b/Avalonia-Services/Core/ServiceEndpointCollection.cs index 170d19e..f88ac85 100644 --- a/Avalonia-Services/Core/ServiceEndpointCollection.cs +++ b/Avalonia-Services/Core/ServiceEndpointCollection.cs @@ -1,3 +1,4 @@ +using Avalonia_Common.Core; using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -177,6 +178,14 @@ namespace Avalonia_Services.Core return AddEndpoint(pattern, "GET", handler); } + /// + /// 注册一个返回统一响应契约的 GET 端点。 + /// + public ServiceEndpoint MapGet(string pattern, Func> handler) + { + return MapGet(pattern, CreateApiResponseHandler(handler)); + } + /// /// 注册一个带服务依赖注入的 GET 端点。 /// @@ -192,6 +201,33 @@ namespace Avalonia_Services.Core return MapGet(pattern, CreateServiceHandler(handler)); } + /// + /// 注册一个带服务依赖注入且返回统一响应契约的 GET 端点。 + /// + public ServiceEndpoint MapGet( + string pattern, + Func> handler) + where TService : notnull + { + return MapGet(pattern, CreateServiceHandler(handler)); + } + + /// + /// 注册一个带查询请求 DTO 和服务依赖注入的 GET 端点。 + /// + public ServiceEndpoint MapGet( + string pattern, + Func> handler) + where TService : notnull + { + var endpoint = MapGet( + pattern, + CreateServiceHandler((service, ctx) => + handler(service, ServiceRequestBinder.BindQuery(ctx), ctx))); + endpoint.OpenApiRequestType ??= typeof(TRequest); + return endpoint; + } + /// /// 注册一个 POST 端点。 /// @@ -200,6 +236,14 @@ namespace Avalonia_Services.Core return AddEndpoint(pattern, "POST", handler); } + /// + /// 注册一个返回统一响应契约的 POST 端点。 + /// + public ServiceEndpoint MapPost(string pattern, Func> handler) + { + return MapPost(pattern, CreateApiResponseHandler(handler)); + } + /// /// 注册一个带服务依赖注入的 POST 端点。 /// @@ -215,6 +259,33 @@ namespace Avalonia_Services.Core return MapPost(pattern, CreateServiceHandler(handler)); } + /// + /// 注册一个带服务依赖注入且返回统一响应契约的 POST 端点。 + /// + public ServiceEndpoint MapPost( + string pattern, + Func> handler) + where TService : notnull + { + return MapPost(pattern, CreateServiceHandler(handler)); + } + + /// + /// 注册一个带 JSON 请求 DTO 和服务依赖注入的 POST 端点。 + /// + public ServiceEndpoint MapPost( + string pattern, + Func> handler) + where TService : notnull + { + var endpoint = MapPost( + pattern, + CreateServiceHandler((service, ctx) => + handler(service, ServiceRequestBinder.BindBody(ctx), ctx))); + endpoint.OpenApiRequestType ??= typeof(TRequest); + return endpoint; + } + /// /// 注册一个 PUT 端点。 /// @@ -223,6 +294,14 @@ namespace Avalonia_Services.Core return AddEndpoint(pattern, "PUT", handler); } + /// + /// 注册一个返回统一响应契约的 PUT 端点。 + /// + public ServiceEndpoint MapPut(string pattern, Func> handler) + { + return MapPut(pattern, CreateApiResponseHandler(handler)); + } + /// /// 注册一个带服务依赖注入的 PUT 端点。 /// @@ -238,6 +317,33 @@ namespace Avalonia_Services.Core return MapPut(pattern, CreateServiceHandler(handler)); } + /// + /// 注册一个带服务依赖注入且返回统一响应契约的 PUT 端点。 + /// + public ServiceEndpoint MapPut( + string pattern, + Func> handler) + where TService : notnull + { + return MapPut(pattern, CreateServiceHandler(handler)); + } + + /// + /// 注册一个带 JSON 请求 DTO 和服务依赖注入的 PUT 端点。 + /// + public ServiceEndpoint MapPut( + string pattern, + Func> handler) + where TService : notnull + { + var endpoint = MapPut( + pattern, + CreateServiceHandler((service, ctx) => + handler(service, ServiceRequestBinder.BindBody(ctx), ctx))); + endpoint.OpenApiRequestType ??= typeof(TRequest); + return endpoint; + } + /// /// 注册一个 DELETE 端点。 /// @@ -246,6 +352,14 @@ namespace Avalonia_Services.Core return AddEndpoint(pattern, "DELETE", handler); } + /// + /// 注册一个返回统一响应契约的 DELETE 端点。 + /// + public ServiceEndpoint MapDelete(string pattern, Func> handler) + { + return MapDelete(pattern, CreateApiResponseHandler(handler)); + } + /// /// 注册一个带服务依赖注入的 DELETE 端点。 /// @@ -261,6 +375,33 @@ namespace Avalonia_Services.Core return MapDelete(pattern, CreateServiceHandler(handler)); } + /// + /// 注册一个带服务依赖注入且返回统一响应契约的 DELETE 端点。 + /// + public ServiceEndpoint MapDelete( + string pattern, + Func> handler) + where TService : notnull + { + return MapDelete(pattern, CreateServiceHandler(handler)); + } + + /// + /// 注册一个带查询请求 DTO 和服务依赖注入的 DELETE 端点。 + /// + public ServiceEndpoint MapDelete( + string pattern, + Func> handler) + where TService : notnull + { + var endpoint = MapDelete( + pattern, + CreateServiceHandler((service, ctx) => + handler(service, ServiceRequestBinder.BindQuery(ctx), ctx))); + endpoint.OpenApiRequestType ??= typeof(TRequest); + return endpoint; + } + /// /// 添加全局过滤器(作用于所有端点)。 /// @@ -318,6 +459,33 @@ namespace Avalonia_Services.Core return await handler(service, ctx); }; } + + /// + /// 将统一响应契约适配为端点集合内部使用的异构响应类型。 + /// + private static Func> CreateApiResponseHandler( + Func> handler) + { + return async ctx => await handler(ctx); + } + + /// + /// 为服务端点创建统一响应契约的 DI 包装。 + /// + private static Func> CreateServiceHandler( + Func> handler) + where TService : notnull + { + return async ctx => + { + var serviceProvider = ctx.Items["ServiceProvider"] as IServiceProvider + ?? throw new InvalidOperationException("ServiceProvider 未注入。"); + + await using var scope = serviceProvider.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + return await handler(service, ctx); + }; + } } /// diff --git a/Avalonia-Services/Core/ServiceEndpointContext.cs b/Avalonia-Services/Core/ServiceEndpointContext.cs index 94be302..463314a 100644 --- a/Avalonia-Services/Core/ServiceEndpointContext.cs +++ b/Avalonia-Services/Core/ServiceEndpointContext.cs @@ -32,6 +32,11 @@ namespace Avalonia_Services.Core /// public Dictionary Query { get; init; } = new(StringComparer.OrdinalIgnoreCase); + /// + /// 路由路径参数。 + /// + public Dictionary RouteValues { get; init; } = new(StringComparer.OrdinalIgnoreCase); + /// /// 响应状态码 /// diff --git a/Avalonia-Services/Core/ServiceEndpointPatternMatcher.cs b/Avalonia-Services/Core/ServiceEndpointPatternMatcher.cs new file mode 100644 index 0000000..364ece1 --- /dev/null +++ b/Avalonia-Services/Core/ServiceEndpointPatternMatcher.cs @@ -0,0 +1,75 @@ +namespace Avalonia_Services.Core +{ + /// + /// Matches unified endpoint patterns and extracts simple route values. + /// + internal static class ServiceEndpointPatternMatcher + { + /// + /// Match literal segments and single-segment route parameters such as {id} or {id:int}. + /// + public static bool TryMatch( + string pattern, + string path, + out Dictionary routeValues) + { + routeValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var patternSegments = SplitSegments(pattern); + var pathSegments = SplitSegments(path); + if (patternSegments.Length != pathSegments.Length) + { + return false; + } + + for (var index = 0; index < patternSegments.Length; index++) + { + var patternSegment = patternSegments[index]; + var pathSegment = pathSegments[index]; + + if (TryGetParameterName(patternSegment, out var parameterName)) + { + if (!MatchesConstraint(patternSegment, pathSegment)) + { + return false; + } + + routeValues[parameterName] = Uri.UnescapeDataString(pathSegment); + continue; + } + + if (!string.Equals(patternSegment, pathSegment, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + + private static string[] SplitSegments(string value) + { + return value.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static bool TryGetParameterName(string segment, out string parameterName) + { + parameterName = string.Empty; + if (segment.Length < 3 || segment[0] != '{' || segment[^1] != '}') + { + return false; + } + + var token = segment[1..^1]; + var constraintIndex = token.IndexOf(':'); + parameterName = constraintIndex >= 0 ? token[..constraintIndex] : token; + return !string.IsNullOrWhiteSpace(parameterName); + } + + private static bool MatchesConstraint(string segment, string value) + { + return !segment.EndsWith(":int}", StringComparison.OrdinalIgnoreCase) + || int.TryParse(value, out _); + } + } +} diff --git a/Avalonia-Services/Core/ServiceRequestBinder.cs b/Avalonia-Services/Core/ServiceRequestBinder.cs new file mode 100644 index 0000000..ff9146c --- /dev/null +++ b/Avalonia-Services/Core/ServiceRequestBinder.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Avalonia_Services.Core +{ + /// + /// Binds unified endpoint request models from JSON bodies or query parameters. + /// + internal static class ServiceRequestBinder + { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; + + /// + /// Bind a JSON request body. Empty bodies are treated as an empty JSON object. + /// + public static T BindBody(ServiceEndpointContext context) + { + var json = string.IsNullOrWhiteSpace(context.Body) ? "{}" : context.Body; + return Deserialize(json, "body"); + } + + /// + /// Bind route and query parameters to a request DTO. + /// + public static T BindQuery(ServiceEndpointContext context) + { + var values = new Dictionary(context.Query, StringComparer.OrdinalIgnoreCase); + foreach (var routeValue in context.RouteValues) + { + values[routeValue.Key] = routeValue.Value; + } + + var json = JsonSerializer.Serialize(values, JsonOptions); + return Deserialize(json, "query"); + } + + private static T Deserialize(string json, string source) + { + try + { + return JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new ArgumentException($"Request {source} cannot be bound to {typeof(T).Name}."); + } + catch (JsonException ex) + { + throw new ArgumentException($"Request {source} cannot be bound to {typeof(T).Name}.", ex); + } + } + } +} diff --git a/Avalonia-Services/Endpoints/AppEndpoints.cs b/Avalonia-Services/Endpoints/AppEndpoints.cs index 060085e..1f4d812 100644 --- a/Avalonia-Services/Endpoints/AppEndpoints.cs +++ b/Avalonia-Services/Endpoints/AppEndpoints.cs @@ -1,10 +1,7 @@ using Avalonia_Common.Core; -using Avalonia_EFCore.Database; -using Avalonia_EFCore.Models; using Avalonia_Services.Core; -using Avalonia_Services.Services; using Avalonia_Services.Services.FileLibrary; -using Microsoft.EntityFrameworkCore; +using Avalonia_Services.Services.QrCode; namespace Avalonia_Services.Endpoints { @@ -35,24 +32,12 @@ namespace Avalonia_Services.Endpoints }); // ---- 业务端点注册 ---- - // 天气预报(从数据库读取) - endpoints.MapGet("api/wData", GetWeatherForecastsAsync) - .WithOpenApi("Weather", "获取天气预报信息。") - .WithName("GetWeatherForecast"); - - // 获取用户(演示从数据库查询) - endpoints.MapGet("api/getUser", GetUserFromDatabaseAsync) - .WithName("GetUser"); - - // 处理数据(POST — 演示参数处理) - 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)) + endpoints.MapGet("api/library/directories", (service, request, _) => service.GetDirectoriesAsync(request)) .WithOpenApi("FileLibrary", "查询服务器目录。") .WithName("GetLibraryDirectories"); @@ -60,34 +45,42 @@ namespace Avalonia_Services.Endpoints .WithOpenApi("FileLibrary", "查询文件库目录。") .WithName("GetLibraryRoots"); - endpoints.MapPost("api/library/roots", (service, ctx) => service.AddRootAsync(ctx)) + endpoints.MapPost("api/library/roots", (service, request, _) => service.AddRootAsync(request)) .WithOpenApi("FileLibrary", "添加文件库目录。") .WithName("AddLibraryRoot"); - endpoints.MapPost("api/library/roots/enabled", (service, ctx) => service.SetRootEnabledAsync(ctx)) + endpoints.MapPost("api/library/roots/enabled", (service, request, _) => service.SetRootEnabledAsync(request)) .WithOpenApi("FileLibrary", "启用或禁用文件库目录。") .WithName("SetLibraryRootEnabled"); - endpoints.MapPost("api/library/roots/delete", (service, ctx) => service.DeleteRootAsync(ctx)) + endpoints.MapPost("api/library/roots/delete", (service, request, _) => service.DeleteRootAsync(request)) .WithOpenApi("FileLibrary", "删除文件库目录。") .WithName("DeleteLibraryRoot"); - endpoints.MapPost("api/library/roots/scan", (service, ctx) => service.ScanRootAsync(ctx)) + endpoints.MapPost("api/library/roots/scan", (service, request, _) => service.ScanRootAsync(request)) .WithOpenApi("FileLibrary", "立即扫描文件库目录。") .WithName("ScanLibraryRoot"); - endpoints.MapGet("api/files", (service, ctx) => service.SearchFilesAsync(ctx)) + endpoints.MapGet("api/files", (service, request, _) => service.SearchFilesAsync(request)) .WithOpenApi("FileLibrary", "分页查询已扫描文件。") .WithName("SearchFiles"); - endpoints.MapGet("api/files/detail", (service, ctx) => service.GetFileAsync(ctx)) + endpoints.MapGet("api/files/detail", (service, request, _) => service.GetFileAsync(request)) .WithOpenApi("FileLibrary", "查询文件详情。") .WithName("GetFileDetail"); - endpoints.MapGet("api/files/text", (service, ctx) => service.GetTextPreviewAsync(ctx)) + endpoints.MapGet("api/files/text", (service, request, _) => service.GetTextPreviewAsync(request)) .WithOpenApi("FileLibrary", "预览文本文件。") .WithName("GetTextPreview"); + endpoints.MapGet("api/files/stream", GetFileStreamAsync) + .WithOpenApi("FileLibrary", "流式传输文件(支持 Range 请求)。") + .WithName("StreamManagedFile"); + + endpoints.MapGet("api/qrcode", (service, ctx) => service.GenerateQrCodeAsync(ctx)) + .WithOpenApi("Utility", "生成局域网访问二维码。") + .WithName("GetQrCode"); + // ---- 需要鉴权的端点示例 ---- // endpoints.MapGet("api/admin/dashboard", AdminDashboardAsync) // .WithName("AdminDashboard") @@ -101,86 +94,16 @@ namespace Avalonia_Services.Endpoints #region 业务处理方法 - /// - /// 从数据库查询天气预报(优先数据库,回退到内存生成)。 - /// - private static async Task GetWeatherForecastsAsync(ServiceEndpointContext ctx) + private static async Task GetFileStreamAsync(ServiceEndpointContext ctx) { var sp = ctx.Items["ServiceProvider"] as IServiceProvider; + var service = sp?.GetService(typeof(IFileStreamService)) as IFileStreamService; + if (service is null) return null; - // 尝试从数据库读取 - if (sp?.GetService(typeof(AppDataContext)) is AppDataContext db) - { - var dbForecasts = await db.WeatherForecasts - .OrderByDescending(f => f.Date) - .Take(5) - .ToListAsync(); + if (!int.TryParse(ctx.Query.GetValueOrDefault("id"), out var id) || id <= 0) + return null; - if (dbForecasts.Count > 0) - { - return ResponseHelper.Ok(dbForecasts, "获取天气预报成功(来自数据库)"); - } - } - - // 回退:内存生成(数据库为空时) - var service = sp?.GetService(typeof(WeatherForecastService)) as WeatherForecastService - ?? new WeatherForecastService(); - - var forecasts = service.GetWeatherForecasts(); - return ResponseHelper.Ok(forecasts, "获取天气预报成功(内存生成)"); - } - - /// - /// 从数据库获取用户信息(演示数据库查询),若无数据则返回演示用户。 - /// - /// 服务端点上下文。 - /// 用户信息。 - private static async Task GetUserFromDatabaseAsync(ServiceEndpointContext ctx) - { - var sp = ctx.Items["ServiceProvider"] as IServiceProvider; - - // 尝试从数据库读取用户 - if (sp?.GetService(typeof(AppDataContext)) is AppDataContext db) - { - var users = await db.Set().Take(1).ToListAsync(); - if (users.Count > 0) - { - return ResponseHelper.Ok(users[0], "获取用户成功(来自数据库)"); - } - } - - // 回退:演示数据 - await Task.Delay(100); - var user = new { id = 1, name = "张三", email = "zhangsan@example.com" }; - return ResponseHelper.Ok(user); - } - - /// - /// 处理前端发送的数据(POST 演示),将数据存入数据库或转为大写返回。 - /// - /// 服务端点上下文。 - /// 处理结果。 - private static async Task ProcessDataAsync(ServiceEndpointContext ctx) - { - var sp = ctx.Items["ServiceProvider"] as IServiceProvider; - - // 演示:将收到的数据存入数据库 - var input = ctx.Body ?? string.Empty; - if (sp?.GetService(typeof(AppDataContext)) is AppDataContext db && !string.IsNullOrWhiteSpace(input)) - { - var forecast = new WeatherForecastEntity - { - Date = DateOnly.FromDateTime(DateTime.Now), - TemperatureC = 20, - Summary = input, - }; - db.WeatherForecasts.Add(forecast); - await db.SaveChangesAsync(); - return ResponseHelper.Ok(forecast, "数据已存入数据库"); - } - - await Task.Delay(200); - return ResponseHelper.Ok(new { input, processed = input.ToUpperInvariant() }); + return await service.GetFileStreamAsync(id); } #endregion diff --git a/Avalonia-Services/Endpoints/AuthEndpoints.cs b/Avalonia-Services/Endpoints/AuthEndpoints.cs index 37d2159..5eafbc0 100644 --- a/Avalonia-Services/Endpoints/AuthEndpoints.cs +++ b/Avalonia-Services/Endpoints/AuthEndpoints.cs @@ -16,17 +16,17 @@ namespace Avalonia_Services.Endpoints { builder.ConfigureEndpoints(endpoints => { - endpoints.MapPost("api/auth/login", (service, ctx) => service.LoginAsync(ctx)) + endpoints.MapPost("api/auth/login", (service, request, ctx) => service.LoginAsync(request, ctx)) .WithName("ApiLogin") .WithOpenApi("Auth", "API 登录,返回 access token 和 refresh token。", "", typeof(ApiLoginRequest), typeof(AuthTokenResponse)) .ApiOnly(); - endpoints.MapPost("api/auth/refresh", (service, ctx) => service.RefreshAsync(ctx)) + endpoints.MapPost("api/auth/refresh", (service, request, ctx) => service.RefreshAsync(request, ctx)) .WithName("ApiRefresh") .WithOpenApi("Auth", "API refresh token 轮换。", "", typeof(ApiRefreshTokenRequest), typeof(AuthTokenResponse)) .ApiOnly(); - endpoints.MapPost("api/auth/logout", (service, ctx) => service.LogoutAsync(ctx)) + endpoints.MapPost("api/auth/logout", (service, request, ctx) => service.LogoutAsync(request, ctx)) .WithName("ApiLogout") .WithOpenApi("Auth", "API 退出登录并吊销 refresh token。", "", typeof(ApiLogoutRequest)) .ApiOnly(); @@ -41,17 +41,17 @@ namespace Avalonia_Services.Endpoints { builder.ConfigureEndpoints(endpoints => { - endpoints.MapPost("api/pc/auth/authorize", (service, ctx) => service.AuthorizeAsync(ctx)) + endpoints.MapPost("api/pc/auth/authorize", (service, request, ctx) => service.AuthorizeAsync(request, ctx)) .WithName("PcAuthorize") .WithOpenApi("Auth", "PC 授权码登录,生成本地全局 token。", "", typeof(PcAuthorizeRequest), typeof(PcTokenResponse)) .PcOnly(); - endpoints.MapPost("api/pc/auth/refresh", (service, ctx) => service.RefreshAsync(ctx)) + endpoints.MapPost("api/pc/auth/refresh", (service, request, ctx) => service.RefreshAsync(request, ctx)) .WithName("PcRefresh") .WithOpenApi("Auth", "PC 全局 token 刷新。", "", typeof(PcRefreshRequest), typeof(PcTokenResponse)) .PcOnly(); - endpoints.MapPost("api/pc/auth/logout", (service, ctx) => service.LogoutAsync(ctx)) + endpoints.MapPost("api/pc/auth/logout", (service, request, ctx) => service.LogoutAsync(request, ctx)) .WithName("PcLogout") .WithOpenApi("Auth", "PC 退出登录。", "", typeof(PcLogoutRequest)) .PcOnly(); diff --git a/Avalonia-Services/Extensions/DesktopEndpointAdapter.cs b/Avalonia-Services/Extensions/DesktopEndpointAdapter.cs index edb4291..d516227 100644 --- a/Avalonia-Services/Extensions/DesktopEndpointAdapter.cs +++ b/Avalonia-Services/Extensions/DesktopEndpointAdapter.cs @@ -109,10 +109,19 @@ namespace Avalonia_Services.Extensions Dictionary? query = null) { // 查找匹配的端点(忽略大小写 + 方法匹配) - var endpoint = _endpoints.Endpoints.FirstOrDefault(e => - e.SupportsHost(EndpointHostTarget.Pc) && - string.Equals(e.Pattern, path, StringComparison.OrdinalIgnoreCase) && - string.Equals(e.HttpMethod, method, StringComparison.OrdinalIgnoreCase)); + var match = _endpoints.Endpoints + .Where(e => + e.SupportsHost(EndpointHostTarget.Pc) && + string.Equals(e.HttpMethod, method, StringComparison.OrdinalIgnoreCase)) + .Select(e => new + { + Endpoint = e, + IsMatched = ServiceEndpointPatternMatcher.TryMatch(e.Pattern, path, out var routeValues), + RouteValues = routeValues, + }) + .FirstOrDefault(candidate => candidate.IsMatched); + + var endpoint = match?.Endpoint; if (endpoint is null) { @@ -127,6 +136,7 @@ namespace Avalonia_Services.Extensions Body = body, Headers = headers ?? new Dictionary(StringComparer.OrdinalIgnoreCase), Query = query ?? new Dictionary(StringComparer.OrdinalIgnoreCase), + RouteValues = match!.RouteValues, Items = { ["ServiceProvider"] = _serviceProvider }, }; diff --git a/Avalonia-Services/Services/AuthService/AuthEndpointServices.cs b/Avalonia-Services/Services/AuthService/AuthEndpointServices.cs index 8218001..fa5b900 100644 --- a/Avalonia-Services/Services/AuthService/AuthEndpointServices.cs +++ b/Avalonia-Services/Services/AuthService/AuthEndpointServices.cs @@ -1,3 +1,4 @@ +using Avalonia_Common.Core; using Avalonia_Services.Core; using System.Threading.Tasks; @@ -13,21 +14,21 @@ namespace Avalonia_Services.Services.AuthService /// /// 服务端点上下文。 /// 包含 Token 的认证响应。 - Task LoginAsync(ServiceEndpointContext ctx); + Task LoginAsync(ApiLoginRequest request, ServiceEndpointContext ctx); /// /// 使用 Refresh Token 刷新 Access Token。 /// /// 服务端点上下文。 /// 新的 Token 对。 - Task RefreshAsync(ServiceEndpointContext ctx); + Task RefreshAsync(ApiRefreshTokenRequest request, ServiceEndpointContext ctx); /// /// 处理用户登出请求。 /// /// 服务端点上下文。 /// 登出结果。 - Task LogoutAsync(ServiceEndpointContext ctx); + Task LogoutAsync(ApiLogoutRequest request, ServiceEndpointContext ctx); } /// @@ -40,20 +41,20 @@ namespace Avalonia_Services.Services.AuthService /// /// 服务端点上下文。 /// 包含 Token 的认证响应。 - Task AuthorizeAsync(ServiceEndpointContext ctx); + Task AuthorizeAsync(PcAuthorizeRequest request, ServiceEndpointContext ctx); /// /// 刷新当前 Token。 /// /// 服务端点上下文。 /// 新的 Token 响应。 - Task RefreshAsync(ServiceEndpointContext ctx); + Task RefreshAsync(PcRefreshRequest request, ServiceEndpointContext ctx); /// /// 处理用户登出请求。 /// /// 服务端点上下文。 /// 登出结果。 - Task LogoutAsync(ServiceEndpointContext ctx); + Task LogoutAsync(PcLogoutRequest request, ServiceEndpointContext ctx); } } diff --git a/Avalonia-Services/Services/FileLibrary/FileLibraryContracts.cs b/Avalonia-Services/Services/FileLibrary/FileLibraryContracts.cs index a0047e8..041ede8 100644 --- a/Avalonia-Services/Services/FileLibrary/FileLibraryContracts.cs +++ b/Avalonia-Services/Services/FileLibrary/FileLibraryContracts.cs @@ -17,6 +17,19 @@ namespace Avalonia_Services.Services.FileLibrary public sealed record DeleteLibraryRootRequest( [property: JsonPropertyName("id")] int Id); + public sealed record DirectoryQueryRequest( + [property: JsonPropertyName("path")] string? Path); + + public sealed record FileQueryRequest( + [property: JsonPropertyName("id")] int Id); + + public sealed record SearchFilesRequest( + [property: JsonPropertyName("page")] int Page = 1, + [property: JsonPropertyName("pageSize")] int PageSize = 24, + [property: JsonPropertyName("mediaType")] string? MediaType = null, + [property: JsonPropertyName("keyword")] string? Keyword = null, + [property: JsonPropertyName("rootId")] int RootId = 0); + public sealed record DriveDto( string Name, string DisplayName, diff --git a/Avalonia-Services/Services/FileLibrary/FileLibraryEndpointService.cs b/Avalonia-Services/Services/FileLibrary/FileLibraryEndpointService.cs index a1c6539..d5d9bf2 100644 --- a/Avalonia-Services/Services/FileLibrary/FileLibraryEndpointService.cs +++ b/Avalonia-Services/Services/FileLibrary/FileLibraryEndpointService.cs @@ -1,99 +1,77 @@ 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) + public async Task GetDrivesAsync(ServiceEndpointContext ctx) { return ResponseHelper.Ok(await fileLibrary.GetDrivesAsync()); } - public async Task GetDirectoriesAsync(ServiceEndpointContext ctx) + public async Task GetDirectoriesAsync(DirectoryQueryRequest request) { - var path = ctx.Query.GetValueOrDefault("path"); - return ResponseHelper.Ok(await fileLibrary.GetDirectoriesAsync(path)); + return ResponseHelper.Ok(await fileLibrary.GetDirectoriesAsync(request.Path)); } - public async Task GetRootsAsync(ServiceEndpointContext ctx) + public async Task GetRootsAsync(ServiceEndpointContext ctx) { return ResponseHelper.Ok(await fileLibrary.GetRootsAsync()); } - public async Task AddRootAsync(ServiceEndpointContext ctx) + public async Task AddRootAsync(AddLibraryRootRequest request) { - var request = ReadBody(ctx); return ResponseHelper.Ok(await fileLibrary.AddRootAsync(request), "文件库目录已添加并完成扫描。"); } - public async Task SetRootEnabledAsync(ServiceEndpointContext ctx) + public async Task SetRootEnabledAsync(UpdateLibraryRootRequest request) { - var request = ReadBody(ctx); return ResponseHelper.Ok(await fileLibrary.SetRootEnabledAsync(request), "文件库目录状态已更新。"); } - public async Task DeleteRootAsync(ServiceEndpointContext ctx) + public async Task DeleteRootAsync(DeleteLibraryRootRequest request) { - var request = ReadBody(ctx); await fileLibrary.DeleteRootAsync(request); return ResponseHelper.Succeed("文件库目录已删除。"); } - public async Task ScanRootAsync(ServiceEndpointContext ctx) + public async Task ScanRootAsync(ScanLibraryRootRequest request) { - var request = ReadBody(ctx); return ResponseHelper.Ok(await fileLibrary.ScanRootAsync(request.Id), "文件库目录扫描完成。"); } - public async Task SearchFilesAsync(ServiceEndpointContext ctx) + public async Task SearchFilesAsync(SearchFilesRequest request) { - return await fileLibrary.SearchFilesAsync(ctx); + return await fileLibrary.SearchFilesAsync(request); } - public async Task GetFileAsync(ServiceEndpointContext ctx) + public async Task GetFileAsync(FileQueryRequest request) { - var id = ReadId(ctx); - var file = await fileLibrary.GetFileAsync(id); + ValidateFileId(request.Id); + var file = await fileLibrary.GetFileAsync(request.Id); return file is null ? ResponseHelper.Failure(404, "文件不存在或尚未扫描入库。") : ResponseHelper.Ok(file); } - public async Task GetTextPreviewAsync(ServiceEndpointContext ctx) + public async Task GetTextPreviewAsync(FileQueryRequest request) { - var id = ReadId(ctx); - var preview = await fileLibrary.GetTextPreviewAsync(id); + ValidateFileId(request.Id); + var preview = await fileLibrary.GetTextPreviewAsync(request.Id); return preview is null ? ResponseHelper.Failure(404, "文本文件不存在或无法预览。") : ResponseHelper.Ok(preview); } - private static T ReadBody(ServiceEndpointContext ctx) + private static void ValidateFileId(int id) { - if (string.IsNullOrWhiteSpace(ctx.Body)) + if (id > 0) { - throw new InvalidOperationException("请求体不能为空。"); + return; } - 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 参数无效。"); + throw new ArgumentException("id 参数无效。"); } } } diff --git a/Avalonia-Services/Services/FileLibrary/FileLibraryService.cs b/Avalonia-Services/Services/FileLibrary/FileLibraryService.cs index ec10cbf..c89d7f7 100644 --- a/Avalonia-Services/Services/FileLibrary/FileLibraryService.cs +++ b/Avalonia-Services/Services/FileLibrary/FileLibraryService.cs @@ -213,13 +213,13 @@ namespace Avalonia_Services.Services.FileLibrary } } - public async Task> SearchFilesAsync(ServiceEndpointContext ctx, CancellationToken cancellationToken = default) + public async Task> SearchFilesAsync(SearchFilesRequest request, 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 page = Math.Clamp(request.Page, 1, 100000); + var pageSize = Math.Clamp(request.PageSize, 1, 100); + var mediaType = request.MediaType?.Trim(); + var keyword = request.Keyword?.Trim(); + var rootId = Math.Clamp(request.RootId, 0, int.MaxValue); var query = db.ManagedFileRecords .AsNoTracking() @@ -399,11 +399,5 @@ namespace Avalonia_Services.Services.FileLibrary 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/FileStreamService.cs b/Avalonia-Services/Services/FileLibrary/FileStreamService.cs new file mode 100644 index 0000000..2886be9 --- /dev/null +++ b/Avalonia-Services/Services/FileLibrary/FileStreamService.cs @@ -0,0 +1,37 @@ +using Avalonia_EFCore.Database; +using Avalonia_EFCore.Models; +using Avalonia_Services.Core; +using Microsoft.EntityFrameworkCore; + +namespace Avalonia_Services.Services.FileLibrary +{ + public interface IFileStreamService + { + Task GetFileStreamAsync(int id, CancellationToken cancellationToken = default); + } + + public sealed class FileStreamService(AppDataContext db) : IFileStreamService + { + public async Task GetFileStreamAsync(int id, CancellationToken cancellationToken = default) + { + var file = await db.ManagedFileRecords + .AsNoTracking() + .Include(item => item.LibraryRoot) + .FirstOrDefaultAsync(item => + item.Id == id + && item.Exists + && item.LibraryRoot != null + && item.LibraryRoot.IsAvailable, + cancellationToken); + + if (file is null || !System.IO.File.Exists(file.AbsolutePath)) + return null; + + return new FileStreamResponse( + file.AbsolutePath, + file.FileName, + file.ContentType, + file.LastWriteTimeUtc); + } + } +} diff --git a/Avalonia-Services/Services/FileLibrary/IFileLibraryEndpointService.cs b/Avalonia-Services/Services/FileLibrary/IFileLibraryEndpointService.cs index 5fc8c32..ee9a4de 100644 --- a/Avalonia-Services/Services/FileLibrary/IFileLibraryEndpointService.cs +++ b/Avalonia-Services/Services/FileLibrary/IFileLibraryEndpointService.cs @@ -1,27 +1,28 @@ +using Avalonia_Common.Core; using Avalonia_Services.Core; namespace Avalonia_Services.Services.FileLibrary { public interface IFileLibraryEndpointService { - Task GetDrivesAsync(ServiceEndpointContext ctx); + Task GetDrivesAsync(ServiceEndpointContext ctx); - Task GetDirectoriesAsync(ServiceEndpointContext ctx); + Task GetDirectoriesAsync(DirectoryQueryRequest request); - Task GetRootsAsync(ServiceEndpointContext ctx); + Task GetRootsAsync(ServiceEndpointContext ctx); - Task AddRootAsync(ServiceEndpointContext ctx); + Task AddRootAsync(AddLibraryRootRequest request); - Task SetRootEnabledAsync(ServiceEndpointContext ctx); + Task SetRootEnabledAsync(UpdateLibraryRootRequest request); - Task DeleteRootAsync(ServiceEndpointContext ctx); + Task DeleteRootAsync(DeleteLibraryRootRequest request); - Task ScanRootAsync(ServiceEndpointContext ctx); + Task ScanRootAsync(ScanLibraryRootRequest request); - Task SearchFilesAsync(ServiceEndpointContext ctx); + Task SearchFilesAsync(SearchFilesRequest request); - Task GetFileAsync(ServiceEndpointContext ctx); + Task GetFileAsync(FileQueryRequest request); - Task GetTextPreviewAsync(ServiceEndpointContext ctx); + Task GetTextPreviewAsync(FileQueryRequest request); } } diff --git a/Avalonia-Services/Services/FileLibrary/IFileLibraryService.cs b/Avalonia-Services/Services/FileLibrary/IFileLibraryService.cs index d814356..e3c5147 100644 --- a/Avalonia-Services/Services/FileLibrary/IFileLibraryService.cs +++ b/Avalonia-Services/Services/FileLibrary/IFileLibraryService.cs @@ -1,5 +1,4 @@ using Avalonia_Common.Core; -using Avalonia_Services.Core; namespace Avalonia_Services.Services.FileLibrary { @@ -21,7 +20,7 @@ namespace Avalonia_Services.Services.FileLibrary Task ScanDueRootsAsync(CancellationToken cancellationToken = default); - Task> SearchFilesAsync(ServiceEndpointContext ctx, CancellationToken cancellationToken = default); + Task> SearchFilesAsync(SearchFilesRequest request, CancellationToken cancellationToken = default); Task GetFileAsync(int id, CancellationToken cancellationToken = default); diff --git a/Avalonia-Services/Services/QrCode/IQrCodeService.cs b/Avalonia-Services/Services/QrCode/IQrCodeService.cs new file mode 100644 index 0000000..286953b --- /dev/null +++ b/Avalonia-Services/Services/QrCode/IQrCodeService.cs @@ -0,0 +1,9 @@ +using Avalonia_Services.Core; + +namespace Avalonia_Services.Services.QrCode +{ + public interface IQrCodeService + { + Task GenerateQrCodeAsync(ServiceEndpointContext ctx); + } +} diff --git a/Avalonia-Services/Services/QrCode/QrCodeContracts.cs b/Avalonia-Services/Services/QrCode/QrCodeContracts.cs new file mode 100644 index 0000000..3119d14 --- /dev/null +++ b/Avalonia-Services/Services/QrCode/QrCodeContracts.cs @@ -0,0 +1,4 @@ +namespace Avalonia_Services.Services.QrCode +{ + public sealed record QrCodeResponse(string Url, string QrCodeBase64); +} diff --git a/Avalonia-Services/Services/QrCode/QrCodeService.cs b/Avalonia-Services/Services/QrCode/QrCodeService.cs new file mode 100644 index 0000000..3be62f4 --- /dev/null +++ b/Avalonia-Services/Services/QrCode/QrCodeService.cs @@ -0,0 +1,46 @@ +using Avalonia_Common.Core; +using Avalonia_Services.Core; +using QRCoder; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; + +namespace Avalonia_Services.Services.QrCode +{ + public sealed class QrCodeService : IQrCodeService + { + public Task GenerateQrCodeAsync(ServiceEndpointContext ctx) + { + var ip = GetLanIpAddress(); + if (ip is null) + throw new InvalidOperationException("无法获取局域网IP地址"); + + var url = $"http://{ip}:5206"; + var base64 = GeneratePngBase64(url); + return Task.FromResult(ResponseHelper.Ok(new QrCodeResponse(url, base64))); + } + + private static string GeneratePngBase64(string content) + { + using var generator = new QRCodeGenerator(); + using var data = generator.CreateQrCode(content, QRCodeGenerator.ECCLevel.Q); + using var png = new PngByteQRCode(data); + var bytes = png.GetGraphic(20); + return $"data:image/png;base64,{Convert.ToBase64String(bytes)}"; + } + + private static string? GetLanIpAddress() + { + return NetworkInterface.GetAllNetworkInterfaces() + .Where(ni => ni.OperationalStatus == OperationalStatus.Up + && ni.NetworkInterfaceType != NetworkInterfaceType.Loopback) + .SelectMany(ni => ni.GetIPProperties().UnicastAddresses) + .Select(ua => ua.Address) + .FirstOrDefault(ip => + ip.AddressFamily == AddressFamily.InterNetwork + && !IPAddress.IsLoopback(ip) + && !ip.ToString().StartsWith("169.254")) + ?.ToString(); + } + } +} diff --git a/Avalonia-Web-VUE/src/App.vue b/Avalonia-Web-VUE/src/App.vue index 0a1f946..71f6b54 100644 --- a/Avalonia-Web-VUE/src/App.vue +++ b/Avalonia-Web-VUE/src/App.vue @@ -21,6 +21,8 @@ const total = ref(0) const loading = ref(false) const scanningId = ref(null) const errorMessage = ref('') +const showQrCode = ref(false) +const qrCodeData = ref<{ url: string; qrCodeBase64: string } | null>(null) const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))) const availableRoots = computed(() => roots.value.filter((root) => root.isAvailable)) @@ -176,6 +178,15 @@ async function refreshAll() { await Promise.all([loadRoots(), loadFiles()]) } +async function loadQrCode() { + try { + qrCodeData.value = await api.qrCode() + showQrCode.value = true + } catch (error) { + setError(error) + } +} + onMounted(async () => { loading.value = true try { @@ -320,6 +331,7 @@ onMounted(async () => {
+ 管理
@@ -431,5 +443,17 @@ onMounted(async () => { {{ page }} / {{ totalPages }} + + +
+
+

扫码访问

+ QR Code +

加载中...

+

使用手机扫描二维码,即可在局域网中打开此网站

+ +
+
+
diff --git a/Avalonia-Web-VUE/src/api/env.ts b/Avalonia-Web-VUE/src/api/env.ts index 5885dcb..40c7876 100644 --- a/Avalonia-Web-VUE/src/api/env.ts +++ b/Avalonia-Web-VUE/src/api/env.ts @@ -1,10 +1,11 @@ // 扩展 Window 接口,声明 C# 桥接注入的全局属性 declare global { - interface Window { +interface Window { /** 由 C# BridgeScript 注入,标记当前运行在 WebView2 环境中 */ isWebView2?: boolean /** 由 WebView2 宿主注入,用于向 C# 发送消息 */ invokeCSharpAction?: (message: string) => void + __pcMediaOrigin?: string } } diff --git a/Avalonia-Web-VUE/src/api/http.ts b/Avalonia-Web-VUE/src/api/http.ts index 48c1657..fb8c038 100644 --- a/Avalonia-Web-VUE/src/api/http.ts +++ b/Avalonia-Web-VUE/src/api/http.ts @@ -16,6 +16,9 @@ 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}` + if (isWebView2() && window.__pcMediaOrigin) { + return `${window.__pcMediaOrigin}${normalized}` + } return `${isWebView2() ? '' : HTTP_ORIGIN}${normalized}` } diff --git a/Avalonia-Web-VUE/src/api/index.ts b/Avalonia-Web-VUE/src/api/index.ts index 7a1aa55..bf14dd1 100644 --- a/Avalonia-Web-VUE/src/api/index.ts +++ b/Avalonia-Web-VUE/src/api/index.ts @@ -90,4 +90,5 @@ export const api = { getTextPreview: (id: number) => request(`files/text${qs({ id })}`), mediaUrl: (path: string) => apiUrl(path), + qrCode: () => request<{ url: string; qrCodeBase64: string }>('qrcode'), } diff --git a/Avalonia-Web-VUE/src/assets/main.css b/Avalonia-Web-VUE/src/assets/main.css index 88823cb..bf2a03c 100644 --- a/Avalonia-Web-VUE/src/assets/main.css +++ b/Avalonia-Web-VUE/src/assets/main.css @@ -595,6 +595,59 @@ a { text-align: center; } +.qr-button { + border-radius: 999px; + padding: 8px 14px; + color: var(--accent-strong); + background: #fff; +} + +.qr-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(16, 24, 40, 0.55); + backdrop-filter: blur(6px); +} + +.qr-modal { + display: grid; + justify-items: center; + gap: 16px; + border-radius: 18px; + padding: 28px 24px 22px; + background: #fff; + box-shadow: 0 24px 60px rgba(16, 24, 40, 0.22); + text-align: center; +} + +.qr-modal h2 { + margin: 0; + font-size: 20px; + font-weight: 800; +} + +.qr-image { + display: block; + width: 240px; + height: 240px; + border: 1px solid var(--line); + border-radius: 12px; +} + +.qr-hint { + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.qr-close { + min-width: 120px; +} + @media (max-width: 1100px) { .admin-layout, .admin-browser { diff --git a/FileShare.slnx b/FileShare.slnx new file mode 100644 index 0000000..743009d --- /dev/null +++ b/FileShare.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + +