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:
2026-05-22 11:42:38 +08:00
parent e72bff954b
commit 5cdc7052e0
13 changed files with 414 additions and 82 deletions
@@ -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>
@@ -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;
}
}
}