feat: 完善统一端点响应与请求绑定框架

- 新增 IApiResponse 统一响应契约,覆盖普通响应和分页响应
- 扩展端点映射,支持 IApiResponse 和带请求 DTO 的 MapXxx 重载
- 增加 Body、Query、Route Values 到请求 DTO 的自动绑定
- 增加 PC 端路由模式匹配,支持 {id} 和 {id:int}
- API 与 PC 端点上下文补充路由参数传递
- 调整 OpenAPI 请求类型处理,避免 GET/DELETE 被标记为 JSON Body
- 鉴权端点迁移到强类型请求绑定和 IApiResponse 返回
- 增加统一端点文件流响应支持
This commit is contained in:
luoqian 2026-05-22 11:42:38 +08:00
parent e72bff954b
commit 5cdc7052e0
13 changed files with 414 additions and 82 deletions

View File

@ -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,
};
/// <summary>
/// 处理用户登录请求。根据账号(邮箱或用户名)查找或创建用户,
/// 生成 JWT Access Token 和 Refresh Token 并返回。
/// </summary>
/// <param name="ctx">服务端点上下文,包含请求体、请求头等信息。</param>
/// <returns>包含 AccessToken、RefreshToken 及过期时间的认证响应。</returns>
public async Task<object?> LoginAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> LoginAsync(ApiLoginRequest request, ServiceEndpointContext ctx)
{
var request = Deserialize<ApiLoginRequest>(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
/// </summary>
/// <param name="ctx">服务端点上下文,包含请求体中的 RefreshToken。</param>
/// <returns>新的 Token 对;若 Refresh Token 无效则返回 401 错误。</returns>
public async Task<object?> RefreshAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> RefreshAsync(ApiRefreshTokenRequest request, ServiceEndpointContext ctx)
{
var request = Deserialize<ApiRefreshTokenRequest>(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
/// </summary>
/// <param name="ctx">服务端点上下文,包含请求体中的 RefreshToken。</param>
/// <returns>登出成功的响应。</returns>
public async Task<object?> LogoutAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> LogoutAsync(ApiLogoutRequest request, ServiceEndpointContext ctx)
{
var request = Deserialize<ApiLogoutRequest>(ctx.Body);
await refreshTokenService.RevokeAsync(request?.RefreshToken);
await refreshTokenService.RevokeAsync(request.RefreshToken);
return ResponseHelper.Succeed("退出成功");
}
/// <summary>
/// 将 JSON 请求体反序列化为指定类型。
/// </summary>
/// <typeparam name="T">目标类型。</typeparam>
/// <param name="body">JSON 请求体字符串,可为空。</param>
/// <returns>反序列化后的对象;若 body 为空则返回默认值。</returns>
private static T? Deserialize<T>(string? body)
{
return string.IsNullOrWhiteSpace(body)
? default
: JsonSerializer.Deserialize<T>(body, JsonOptions);
}
/// <summary>
/// 从上下文的 Items 中提取 ASP.NET Core HttpContext并获取客户端远程 IP 地址。
/// </summary>

View File

@ -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;
}
}
}

View File

@ -2,12 +2,24 @@ using System.Text.Json.Serialization;
namespace Avalonia_Common.Core
{
/// <summary>
/// 统一端点响应契约。
/// </summary>
public interface IApiResponse
{
/// <summary>是否成功。</summary>
bool Success { get; }
/// <summary>业务状态码。</summary>
int Code { get; }
}
/// <summary>
/// 统一 API 返回格式。
/// 所有接口的返回都包装为此格式,确保前端收到一致的数据结构。
/// </summary>
/// <typeparam name="T">业务数据类型</typeparam>
public class ApiResponse<T>
public class ApiResponse<T> : IApiResponse
{
/// <summary>是否成功</summary>
[JsonPropertyName("success")]
@ -113,7 +125,7 @@ namespace Avalonia_Common.Core
/// <summary>
/// 分页返回格式
/// </summary>
public class PagedResponse<T>
public class PagedResponse<T> : IApiResponse
{
/// <summary>
/// 获取或设置操作是否成功。

View File

@ -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
/// </summary>
public sealed class PcAuthEndpointService(PcGlobalTokenService tokenService) : IPcAuthEndpointService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
/// <inheritdoc />
public async Task<object?> AuthorizeAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> AuthorizeAsync(PcAuthorizeRequest request, ServiceEndpointContext ctx)
{
var request = Deserialize<PcAuthorizeRequest>(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
}
/// <inheritdoc />
public async Task<object?> RefreshAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> RefreshAsync(PcRefreshRequest request, ServiceEndpointContext ctx)
{
var request = Deserialize<PcRefreshRequest>(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
}
/// <inheritdoc />
public Task<object?> LogoutAsync(ServiceEndpointContext ctx)
public Task<IApiResponse> LogoutAsync(PcLogoutRequest request, ServiceEndpointContext ctx)
{
var request = Deserialize<PcLogoutRequest>(ctx.Body);
var token = request?.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization"));
var token = request.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization"));
tokenService.Logout(token);
return Task.FromResult<object?>(ResponseHelper.Succeed("退出成功"));
}
/// <summary>
/// 将 JSON 请求体反序列化为指定类型。
/// </summary>
/// <typeparam name="T">目标类型。</typeparam>
/// <param name="body">JSON 请求体字符串,可为空。</param>
/// <returns>反序列化后的对象;若 body 为空则返回默认值。</returns>
private static T? Deserialize<T>(string? body)
{
return string.IsNullOrWhiteSpace(body)
? default
: JsonSerializer.Deserialize<T>(body, JsonOptions);
return Task.FromResult<IApiResponse>(ResponseHelper.Succeed("退出成功"));
}
/// <summary>

View File

@ -0,0 +1,11 @@
namespace Avalonia_Services.Core
{
/// <summary>
/// 文件流响应 —— 管道检测到此类型时将返回原始文件而非 JSON。
/// </summary>
public sealed record FileStreamResponse(
string FilePath,
string FileName,
string ContentType,
DateTime LastModified);
}

View File

@ -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);
}
/// <summary>
/// 注册一个返回统一响应契约的 GET 端点。
/// </summary>
public ServiceEndpoint MapGet(string pattern, Func<ServiceEndpointContext, Task<IApiResponse>> handler)
{
return MapGet(pattern, CreateApiResponseHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入的 GET 端点。
/// </summary>
@ -192,6 +201,33 @@ namespace Avalonia_Services.Core
return MapGet(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入且返回统一响应契约的 GET 端点。
/// </summary>
public ServiceEndpoint MapGet<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
return MapGet(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带查询请求 DTO 和服务依赖注入的 GET 端点。
/// </summary>
public ServiceEndpoint MapGet<TService, TRequest>(
string pattern,
Func<TService, TRequest, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
var endpoint = MapGet(
pattern,
CreateServiceHandler<TService>((service, ctx) =>
handler(service, ServiceRequestBinder.BindQuery<TRequest>(ctx), ctx)));
endpoint.OpenApiRequestType ??= typeof(TRequest);
return endpoint;
}
/// <summary>
/// 注册一个 POST 端点。
/// </summary>
@ -200,6 +236,14 @@ namespace Avalonia_Services.Core
return AddEndpoint(pattern, "POST", handler);
}
/// <summary>
/// 注册一个返回统一响应契约的 POST 端点。
/// </summary>
public ServiceEndpoint MapPost(string pattern, Func<ServiceEndpointContext, Task<IApiResponse>> handler)
{
return MapPost(pattern, CreateApiResponseHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入的 POST 端点。
/// </summary>
@ -215,6 +259,33 @@ namespace Avalonia_Services.Core
return MapPost(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入且返回统一响应契约的 POST 端点。
/// </summary>
public ServiceEndpoint MapPost<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
return MapPost(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带 JSON 请求 DTO 和服务依赖注入的 POST 端点。
/// </summary>
public ServiceEndpoint MapPost<TService, TRequest>(
string pattern,
Func<TService, TRequest, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
var endpoint = MapPost(
pattern,
CreateServiceHandler<TService>((service, ctx) =>
handler(service, ServiceRequestBinder.BindBody<TRequest>(ctx), ctx)));
endpoint.OpenApiRequestType ??= typeof(TRequest);
return endpoint;
}
/// <summary>
/// 注册一个 PUT 端点。
/// </summary>
@ -223,6 +294,14 @@ namespace Avalonia_Services.Core
return AddEndpoint(pattern, "PUT", handler);
}
/// <summary>
/// 注册一个返回统一响应契约的 PUT 端点。
/// </summary>
public ServiceEndpoint MapPut(string pattern, Func<ServiceEndpointContext, Task<IApiResponse>> handler)
{
return MapPut(pattern, CreateApiResponseHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入的 PUT 端点。
/// </summary>
@ -238,6 +317,33 @@ namespace Avalonia_Services.Core
return MapPut(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入且返回统一响应契约的 PUT 端点。
/// </summary>
public ServiceEndpoint MapPut<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
return MapPut(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带 JSON 请求 DTO 和服务依赖注入的 PUT 端点。
/// </summary>
public ServiceEndpoint MapPut<TService, TRequest>(
string pattern,
Func<TService, TRequest, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
var endpoint = MapPut(
pattern,
CreateServiceHandler<TService>((service, ctx) =>
handler(service, ServiceRequestBinder.BindBody<TRequest>(ctx), ctx)));
endpoint.OpenApiRequestType ??= typeof(TRequest);
return endpoint;
}
/// <summary>
/// 注册一个 DELETE 端点。
/// </summary>
@ -246,6 +352,14 @@ namespace Avalonia_Services.Core
return AddEndpoint(pattern, "DELETE", handler);
}
/// <summary>
/// 注册一个返回统一响应契约的 DELETE 端点。
/// </summary>
public ServiceEndpoint MapDelete(string pattern, Func<ServiceEndpointContext, Task<IApiResponse>> handler)
{
return MapDelete(pattern, CreateApiResponseHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入的 DELETE 端点。
/// </summary>
@ -261,6 +375,33 @@ namespace Avalonia_Services.Core
return MapDelete(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入且返回统一响应契约的 DELETE 端点。
/// </summary>
public ServiceEndpoint MapDelete<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
return MapDelete(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带查询请求 DTO 和服务依赖注入的 DELETE 端点。
/// </summary>
public ServiceEndpoint MapDelete<TService, TRequest>(
string pattern,
Func<TService, TRequest, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
var endpoint = MapDelete(
pattern,
CreateServiceHandler<TService>((service, ctx) =>
handler(service, ServiceRequestBinder.BindQuery<TRequest>(ctx), ctx)));
endpoint.OpenApiRequestType ??= typeof(TRequest);
return endpoint;
}
/// <summary>
/// 添加全局过滤器(作用于所有端点)。
/// </summary>
@ -318,6 +459,33 @@ namespace Avalonia_Services.Core
return await handler(service, ctx);
};
}
/// <summary>
/// 将统一响应契约适配为端点集合内部使用的异构响应类型。
/// </summary>
private static Func<ServiceEndpointContext, Task<object?>> CreateApiResponseHandler(
Func<ServiceEndpointContext, Task<IApiResponse>> handler)
{
return async ctx => await handler(ctx);
}
/// <summary>
/// 为服务端点创建统一响应契约的 DI 包装。
/// </summary>
private static Func<ServiceEndpointContext, Task<IApiResponse>> CreateServiceHandler<TService>(
Func<TService, ServiceEndpointContext, Task<IApiResponse>> 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<TService>();
return await handler(service, ctx);
};
}
}
/// <summary>

View File

@ -32,6 +32,11 @@ namespace Avalonia_Services.Core
/// </summary>
public Dictionary<string, string> Query { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 路由路径参数。
/// </summary>
public Dictionary<string, string> RouteValues { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 响应状态码
/// </summary>

View File

@ -0,0 +1,75 @@
namespace Avalonia_Services.Core
{
/// <summary>
/// Matches unified endpoint patterns and extracts simple route values.
/// </summary>
internal static class ServiceEndpointPatternMatcher
{
/// <summary>
/// Match literal segments and single-segment route parameters such as {id} or {id:int}.
/// </summary>
public static bool TryMatch(
string pattern,
string path,
out Dictionary<string, string> routeValues)
{
routeValues = new Dictionary<string, string>(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 _);
}
}
}

View File

@ -0,0 +1,53 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Avalonia_Services.Core
{
/// <summary>
/// Binds unified endpoint request models from JSON bodies or query parameters.
/// </summary>
internal static class ServiceRequestBinder
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
NumberHandling = JsonNumberHandling.AllowReadingFromString,
};
/// <summary>
/// Bind a JSON request body. Empty bodies are treated as an empty JSON object.
/// </summary>
public static T BindBody<T>(ServiceEndpointContext context)
{
var json = string.IsNullOrWhiteSpace(context.Body) ? "{}" : context.Body;
return Deserialize<T>(json, "body");
}
/// <summary>
/// Bind route and query parameters to a request DTO.
/// </summary>
public static T BindQuery<T>(ServiceEndpointContext context)
{
var values = new Dictionary<string, string>(context.Query, StringComparer.OrdinalIgnoreCase);
foreach (var routeValue in context.RouteValues)
{
values[routeValue.Key] = routeValue.Value;
}
var json = JsonSerializer.Serialize(values, JsonOptions);
return Deserialize<T>(json, "query");
}
private static T Deserialize<T>(string json, string source)
{
try
{
return JsonSerializer.Deserialize<T>(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);
}
}
}
}

View File

@ -63,7 +63,7 @@ namespace Avalonia_Services.Endpoints
/// <summary>
/// 从数据库查询天气预报(优先数据库,回退到内存生成)。
/// </summary>
private static async Task<object?> GetWeatherForecastsAsync(ServiceEndpointContext ctx)
private static async Task<IApiResponse> GetWeatherForecastsAsync(ServiceEndpointContext ctx)
{
var sp = ctx.Items["ServiceProvider"] as IServiceProvider;
@ -94,7 +94,7 @@ namespace Avalonia_Services.Endpoints
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>用户信息。</returns>
private static async Task<object?> GetUserFromDatabaseAsync(ServiceEndpointContext ctx)
private static async Task<IApiResponse> GetUserFromDatabaseAsync(ServiceEndpointContext ctx)
{
var sp = ctx.Items["ServiceProvider"] as IServiceProvider;
@ -119,7 +119,7 @@ namespace Avalonia_Services.Endpoints
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>处理结果。</returns>
private static async Task<object?> ProcessDataAsync(ServiceEndpointContext ctx)
private static async Task<IApiResponse> ProcessDataAsync(ServiceEndpointContext ctx)
{
var sp = ctx.Items["ServiceProvider"] as IServiceProvider;

View File

@ -16,17 +16,23 @@ namespace Avalonia_Services.Endpoints
{
builder.ConfigureEndpoints(endpoints =>
{
endpoints.MapPost<IApiAuthEndpointService>("api/auth/login", (service, ctx) => service.LoginAsync(ctx))
endpoints.MapPost<IApiAuthEndpointService, ApiLoginRequest>(
"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<IApiAuthEndpointService>("api/auth/refresh", (service, ctx) => service.RefreshAsync(ctx))
endpoints.MapPost<IApiAuthEndpointService, ApiRefreshTokenRequest>(
"api/auth/refresh",
(service, request, ctx) => service.RefreshAsync(request, ctx))
.WithName("ApiRefresh")
.WithOpenApi("Auth", "API refresh token 轮换。", "", typeof(ApiRefreshTokenRequest), typeof(AuthTokenResponse))
.ApiOnly();
endpoints.MapPost<IApiAuthEndpointService>("api/auth/logout", (service, ctx) => service.LogoutAsync(ctx))
endpoints.MapPost<IApiAuthEndpointService, ApiLogoutRequest>(
"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<IPcAuthEndpointService>("api/pc/auth/authorize", (service, ctx) => service.AuthorizeAsync(ctx))
endpoints.MapPost<IPcAuthEndpointService, PcAuthorizeRequest>(
"api/pc/auth/authorize",
(service, request, ctx) => service.AuthorizeAsync(request, ctx))
.WithName("PcAuthorize")
.WithOpenApi("Auth", "PC 授权码登录,生成本地全局 token。", "", typeof(PcAuthorizeRequest), typeof(PcTokenResponse))
.PcOnly();
endpoints.MapPost<IPcAuthEndpointService>("api/pc/auth/refresh", (service, ctx) => service.RefreshAsync(ctx))
endpoints.MapPost<IPcAuthEndpointService, PcRefreshRequest>(
"api/pc/auth/refresh",
(service, request, ctx) => service.RefreshAsync(request, ctx))
.WithName("PcRefresh")
.WithOpenApi("Auth", "PC 全局 token 刷新。", "", typeof(PcRefreshRequest), typeof(PcTokenResponse))
.PcOnly();
endpoints.MapPost<IPcAuthEndpointService>("api/pc/auth/logout", (service, ctx) => service.LogoutAsync(ctx))
endpoints.MapPost<IPcAuthEndpointService, PcLogoutRequest>(
"api/pc/auth/logout",
(service, request, ctx) => service.LogoutAsync(request, ctx))
.WithName("PcLogout")
.WithOpenApi("Auth", "PC 退出登录。", "", typeof(PcLogoutRequest))
.PcOnly();

View File

@ -109,10 +109,19 @@ namespace Avalonia_Services.Extensions
Dictionary<string, string>? 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<string, string>(StringComparer.OrdinalIgnoreCase),
Query = query ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
RouteValues = match!.RouteValues,
Items = { ["ServiceProvider"] = _serviceProvider },
};

View File

@ -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
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>包含 Token 的认证响应。</returns>
Task<object?> LoginAsync(ServiceEndpointContext ctx);
Task<IApiResponse> LoginAsync(ApiLoginRequest request, ServiceEndpointContext ctx);
/// <summary>
/// 使用 Refresh Token 刷新 Access Token。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>新的 Token 对。</returns>
Task<object?> RefreshAsync(ServiceEndpointContext ctx);
Task<IApiResponse> RefreshAsync(ApiRefreshTokenRequest request, ServiceEndpointContext ctx);
/// <summary>
/// 处理用户登出请求。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>登出结果。</returns>
Task<object?> LogoutAsync(ServiceEndpointContext ctx);
Task<IApiResponse> LogoutAsync(ApiLogoutRequest request, ServiceEndpointContext ctx);
}
/// <summary>
@ -40,20 +41,20 @@ namespace Avalonia_Services.Services.AuthService
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>包含 Token 的认证响应。</returns>
Task<object?> AuthorizeAsync(ServiceEndpointContext ctx);
Task<IApiResponse> AuthorizeAsync(PcAuthorizeRequest request, ServiceEndpointContext ctx);
/// <summary>
/// 刷新当前 Token。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>新的 Token 响应。</returns>
Task<object?> RefreshAsync(ServiceEndpointContext ctx);
Task<IApiResponse> RefreshAsync(PcRefreshRequest request, ServiceEndpointContext ctx);
/// <summary>
/// 处理用户登出请求。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>登出结果。</returns>
Task<object?> LogoutAsync(ServiceEndpointContext ctx);
Task<IApiResponse> LogoutAsync(PcLogoutRequest request, ServiceEndpointContext ctx);
}
}