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/Extensions/UnifiedEndpointExtensions.cs b/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs index 234541f..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"); } @@ -131,9 +132,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); @@ -144,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(); }; } @@ -173,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); @@ -204,6 +232,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 +240,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 +248,7 @@ namespace Avalonia_API.Extensions return Results.Json(ctx.ResponseBody, statusCode: ctx.StatusCode); } - return null!; + return nextResult; } } } 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-Services/Core/FileStreamResponse.cs b/Avalonia-Services/Core/FileStreamResponse.cs new file mode 100644 index 0000000..37406aa --- /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 f2e9dee..6461a4c 100644 --- a/Avalonia-Services/Endpoints/AppEndpoints.cs +++ b/Avalonia-Services/Endpoints/AppEndpoints.cs @@ -63,7 +63,7 @@ namespace Avalonia_Services.Endpoints /// /// 从数据库查询天气预报(优先数据库,回退到内存生成)。 /// - private static async Task GetWeatherForecastsAsync(ServiceEndpointContext ctx) + private static async Task GetWeatherForecastsAsync(ServiceEndpointContext ctx) { var sp = ctx.Items["ServiceProvider"] as IServiceProvider; @@ -94,7 +94,7 @@ namespace Avalonia_Services.Endpoints /// /// 服务端点上下文。 /// 用户信息。 - private static async Task GetUserFromDatabaseAsync(ServiceEndpointContext ctx) + private static async Task GetUserFromDatabaseAsync(ServiceEndpointContext ctx) { var sp = ctx.Items["ServiceProvider"] as IServiceProvider; @@ -119,7 +119,7 @@ namespace Avalonia_Services.Endpoints /// /// 服务端点上下文。 /// 处理结果。 - private static async Task ProcessDataAsync(ServiceEndpointContext ctx) + private static async Task ProcessDataAsync(ServiceEndpointContext ctx) { var sp = ctx.Items["ServiceProvider"] as IServiceProvider; diff --git a/Avalonia-Services/Endpoints/AuthEndpoints.cs b/Avalonia-Services/Endpoints/AuthEndpoints.cs index 37d2159..a925bea 100644 --- a/Avalonia-Services/Endpoints/AuthEndpoints.cs +++ b/Avalonia-Services/Endpoints/AuthEndpoints.cs @@ -16,17 +16,23 @@ 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 +47,23 @@ 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); } }