feat: 二维码访问功能,统一端点管道增强,端点迁移至 Services 层
- 新增二维码生成端点,自动检测局域网 IP,前端扫一扫即可打开网站 - 提取 IApiResponse 接口,ServiceRequestBinder 支持强类型请求 DTO 绑定 - FileStream 端点迁移至 AppEndpoints 统一注册,管道支持 FileStreamResponse 原始文件返回 - 文件库端点全面使用 MapGet<TService, TRequest> 泛型注册 - 移除 Avalonia-API/Extensions 中的业务端点文件,统一由 Services 层管理
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -41,10 +41,7 @@
|
||||
<ItemGroup>
|
||||
<FrontendDist Include="..\Avalonia-Web-VUE\dist\**\*.*" />
|
||||
</ItemGroup>
|
||||
<Copy
|
||||
SourceFiles="@(FrontendDist)"
|
||||
DestinationFiles="@(FrontendDist->'wwwroot\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="false" />
|
||||
<Copy SourceFiles="@(FrontendDist)" DestinationFiles="@(FrontendDist->'wwwroot\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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<WeatherForecastService>();
|
||||
services.AddScoped<IFileLibraryService, FileLibraryService>();
|
||||
services.AddScoped<IFileLibraryEndpointService, FileLibraryEndpointService>();
|
||||
services.AddScoped<IFileStreamService, FileStreamService>();
|
||||
services.AddScoped<IQrCodeService, QrCodeService>();
|
||||
services.AddHostedService<FileLibraryScanHostedService>();
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "data-protection-keys")));
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
using Avalonia_EFCore.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Avalonia_Services.Services.FileLibrary;
|
||||
|
||||
namespace Avalonia_API.Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// API-only raw file stream endpoints used by browser media elements.
|
||||
/// </summary>
|
||||
public static class FileStreamEndpointExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Map the media URL emitted by <see cref="FileRecordDto"/>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user