feat: 二维码访问功能,统一端点管道增强,端点迁移至 Services 层

- 新增二维码生成端点,自动检测局域网 IP,前端扫一扫即可打开网站
  - 提取 IApiResponse 接口,ServiceRequestBinder 支持强类型请求 DTO 绑定
  - FileStream 端点迁移至 AppEndpoints 统一注册,管道支持 FileStreamResponse 原始文件返回
  - 文件库端点全面使用 MapGet<TService, TRequest> 泛型注册
  - 移除 Avalonia-API/Extensions 中的业务端点文件,统一由 Services 层管理
This commit is contained in:
2026-05-22 11:18:47 +08:00
parent a16c32b25e
commit d84bbb3a18
35 changed files with 888 additions and 284 deletions
@@ -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);