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

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

1
.gitignore vendored
View File

@ -35,3 +35,4 @@
/.vs
/bin
/obj
/.claude

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

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

View File

@ -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")));

View File

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

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

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

@ -8,6 +8,7 @@ using Avalonia_Services.Core;
using Avalonia_Services.Endpoints;
using Avalonia_Services.Services;
using Avalonia_Services.Services.AuthService;
using Avalonia_Services.Services.FileLibrary;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using System;
@ -70,6 +71,9 @@ namespace Avalonia_PC
services.AddSingleton<PcGlobalTokenService>();
services.AddSingleton<IAuthService, PcAuthService>();
services.AddSingleton<IPcAuthEndpointService, PcAuthEndpointService>();
services.AddScoped<IFileLibraryService, FileLibraryService>();
services.AddScoped<IFileLibraryEndpointService, FileLibraryEndpointService>();
services.AddScoped<IFileStreamService, FileStreamService>();
// ---- 端点注册 ----
var endpointBuilder = new ServiceEndpointBuilder();

View File

@ -9,6 +9,8 @@ using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Avalonia_Services.Services.FileLibrary;
using Microsoft.Extensions.DependencyInjection;
namespace Avalonia_PC.Views
{
@ -207,21 +209,27 @@ namespace Avalonia_PC.Views
return;
}
var localHtmlPath = GetConfiguredLocalStartupPath();
var localRoot = !string.IsNullOrWhiteSpace(localHtmlPath)
? Path.GetDirectoryName(localHtmlPath)
: null;
if (!string.IsNullOrWhiteSpace(localRoot))
{
await EnsureLocalHttpServerStartedAsync(localRoot);
}
var onlineUrl = GetConfiguredOnlineStartupUrl();
if (onlineUrl is not null)
{
StopLocalHttpServer();
_webView.Source = onlineUrl;
return;
}
var localHtmlPath = GetConfiguredLocalStartupPath();
if (string.IsNullOrWhiteSpace(localHtmlPath) || !File.Exists(localHtmlPath))
{
return;
}
var localRoot = Path.GetDirectoryName(localHtmlPath);
if (string.IsNullOrWhiteSpace(localRoot))
{
return;
@ -248,6 +256,11 @@ namespace Avalonia_PC.Views
}
await _webView.InvokeScript(BridgeScript);
if (!string.IsNullOrWhiteSpace(_localHttpBaseUrl))
{
var mediaOriginLiteral = JsonSerializer.Serialize(_localHttpBaseUrl.TrimEnd('/'));
await _webView.InvokeScript($"window.__pcMediaOrigin = {mediaOriginLiteral}");
}
}
#endregion
@ -514,7 +527,7 @@ namespace Avalonia_PC.Views
/// <summary>
/// 本地静态服务主循环,持续接收并分发请求。
/// </summary>
private static async Task RunLocalHttpServerLoopAsync(HttpListener listener, CancellationToken cancellationToken, string wwwRoot)
private async Task RunLocalHttpServerLoopAsync(HttpListener listener, CancellationToken cancellationToken, string wwwRoot)
{
try
{
@ -532,10 +545,15 @@ namespace Avalonia_PC.Views
/// <summary>
/// 处理本地静态资源请求并返回文件内容。
/// </summary>
private static async Task HandleLocalHttpRequest(HttpListenerContext context, string wwwRoot)
private async Task HandleLocalHttpRequest(HttpListenerContext context, string wwwRoot)
{
try
{
if (await TryHandleLocalMediaStreamAsync(context))
{
return;
}
var relativePath = context.Request.Url?.AbsolutePath.TrimStart('/') ?? string.Empty;
if (string.IsNullOrWhiteSpace(relativePath))
{
@ -572,6 +590,170 @@ namespace Avalonia_PC.Views
}
}
/// <summary>
/// Handle media element requests using the same stream path as Avalonia-API.
/// </summary>
private async Task<bool> TryHandleLocalMediaStreamAsync(HttpListenerContext context)
{
var request = context.Request;
var response = context.Response;
var segments = (request.Url?.AbsolutePath ?? string.Empty)
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length != 4
|| !string.Equals(segments[0], "api", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(segments[1], "files", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(segments[3], "stream", StringComparison.OrdinalIgnoreCase))
{
return false;
}
AddLocalMediaHeaders(response);
if (string.Equals(request.HttpMethod, "OPTIONS", StringComparison.OrdinalIgnoreCase))
{
response.StatusCode = (int)HttpStatusCode.NoContent;
response.Close();
return true;
}
if (!string.Equals(request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.HttpMethod, "HEAD", StringComparison.OrdinalIgnoreCase))
{
response.StatusCode = (int)HttpStatusCode.MethodNotAllowed;
response.Close();
return true;
}
if (!int.TryParse(segments[2], out var id) || id <= 0)
{
response.StatusCode = (int)HttpStatusCode.NotFound;
response.Close();
return true;
}
using var scope = _services.CreateScope();
var fileStreamService = scope.ServiceProvider.GetService<IFileStreamService>();
var fileResponse = fileStreamService is null ? null : await fileStreamService.GetFileStreamAsync(id);
if (fileResponse is null || !File.Exists(fileResponse.FilePath))
{
response.StatusCode = (int)HttpStatusCode.NotFound;
response.Close();
return true;
}
var length = new FileInfo(fileResponse.FilePath).Length;
var start = 0L;
var end = length > 0 ? length - 1 : 0;
var isRange = TryParseByteRange(request.Headers["Range"], length, out start, out end);
if (!string.IsNullOrWhiteSpace(request.Headers["Range"]) && !isRange)
{
response.StatusCode = (int)HttpStatusCode.RequestedRangeNotSatisfiable;
response.Headers["Content-Range"] = $"bytes */{length}";
response.Close();
return true;
}
var contentLength = length == 0 ? 0 : end - start + 1;
response.StatusCode = isRange
? (int)HttpStatusCode.PartialContent
: (int)HttpStatusCode.OK;
response.ContentType = fileResponse.ContentType;
response.ContentLength64 = contentLength;
response.Headers["Accept-Ranges"] = "bytes";
response.Headers["Cache-Control"] = "public, max-age=3600";
response.Headers["Content-Disposition"] =
$"inline; filename=\"{Uri.EscapeDataString(fileResponse.FileName)}\"";
response.Headers["Last-Modified"] = fileResponse.LastModified.ToUniversalTime().ToString("R");
if (isRange)
{
response.Headers["Content-Range"] = $"bytes {start}-{end}/{length}";
}
if (string.Equals(request.HttpMethod, "HEAD", StringComparison.OrdinalIgnoreCase)
|| contentLength == 0)
{
response.Close();
return true;
}
await using var input = File.Open(fileResponse.FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
input.Seek(start, SeekOrigin.Begin);
await CopyRangeAsync(input, response.OutputStream, contentLength);
response.OutputStream.Close();
return true;
}
private static void AddLocalMediaHeaders(HttpListenerResponse response)
{
response.Headers["Access-Control-Allow-Origin"] = "*";
response.Headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS";
response.Headers["Access-Control-Allow-Headers"] = "Range, Content-Type, Authorization";
response.Headers["Access-Control-Expose-Headers"] = "Accept-Ranges, Content-Length, Content-Range";
}
private static bool TryParseByteRange(string? value, long length, out long start, out long end)
{
start = 0;
end = length > 0 ? length - 1 : 0;
if (length <= 0 || string.IsNullOrWhiteSpace(value) || !value.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var range = value["bytes=".Length..].Split(',', 2)[0].Trim();
var separatorIndex = range.IndexOf('-');
if (separatorIndex < 0)
{
return false;
}
var startValue = range[..separatorIndex];
var endValue = range[(separatorIndex + 1)..];
if (string.IsNullOrWhiteSpace(startValue))
{
if (!long.TryParse(endValue, out var suffixLength) || suffixLength <= 0)
{
return false;
}
start = Math.Max(0, length - suffixLength);
end = length - 1;
return true;
}
if (!long.TryParse(startValue, out start) || start < 0 || start >= length)
{
return false;
}
if (!string.IsNullOrWhiteSpace(endValue)
&& (!long.TryParse(endValue, out end) || end < start))
{
return false;
}
end = Math.Min(end, length - 1);
return true;
}
private static async Task CopyRangeAsync(Stream input, Stream output, long bytesRemaining)
{
var buffer = new byte[64 * 1024];
while (bytesRemaining > 0)
{
var readLength = (int)Math.Min(buffer.Length, bytesRemaining);
var read = await input.ReadAsync(buffer.AsMemory(0, readLength));
if (read <= 0)
{
break;
}
await output.WriteAsync(buffer.AsMemory(0, read));
bytesRemaining -= read;
}
}
/// <summary>
/// 根据后缀返回静态资源 Content-Type。
/// </summary>

View File

@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />

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

@ -1,10 +1,7 @@
using Avalonia_Common.Core;
using Avalonia_EFCore.Database;
using Avalonia_EFCore.Models;
using Avalonia_Services.Core;
using Avalonia_Services.Services;
using Avalonia_Services.Services.FileLibrary;
using Microsoft.EntityFrameworkCore;
using Avalonia_Services.Services.QrCode;
namespace Avalonia_Services.Endpoints
{
@ -35,24 +32,12 @@ namespace Avalonia_Services.Endpoints
});
// ---- 业务端点注册 ----
// 天气预报(从数据库读取)
endpoints.MapGet("api/wData", GetWeatherForecastsAsync)
.WithOpenApi("Weather", "获取天气预报信息。")
.WithName("GetWeatherForecast");
// 获取用户(演示从数据库查询)
endpoints.MapGet("api/getUser", GetUserFromDatabaseAsync)
.WithName("GetUser");
// 处理数据POST — 演示参数处理)
endpoints.MapPost("api/processData", ProcessDataAsync)
.WithName("ProcessData");
endpoints.MapGet<IFileLibraryEndpointService>("api/library/drives", (service, ctx) => service.GetDrivesAsync(ctx))
.WithOpenApi("FileLibrary", "查询服务器磁盘。")
.WithName("GetLibraryDrives");
endpoints.MapGet<IFileLibraryEndpointService>("api/library/directories", (service, ctx) => service.GetDirectoriesAsync(ctx))
endpoints.MapGet<IFileLibraryEndpointService, DirectoryQueryRequest>("api/library/directories", (service, request, _) => service.GetDirectoriesAsync(request))
.WithOpenApi("FileLibrary", "查询服务器目录。")
.WithName("GetLibraryDirectories");
@ -60,34 +45,42 @@ namespace Avalonia_Services.Endpoints
.WithOpenApi("FileLibrary", "查询文件库目录。")
.WithName("GetLibraryRoots");
endpoints.MapPost<IFileLibraryEndpointService>("api/library/roots", (service, ctx) => service.AddRootAsync(ctx))
endpoints.MapPost<IFileLibraryEndpointService, AddLibraryRootRequest>("api/library/roots", (service, request, _) => service.AddRootAsync(request))
.WithOpenApi("FileLibrary", "添加文件库目录。")
.WithName("AddLibraryRoot");
endpoints.MapPost<IFileLibraryEndpointService>("api/library/roots/enabled", (service, ctx) => service.SetRootEnabledAsync(ctx))
endpoints.MapPost<IFileLibraryEndpointService, UpdateLibraryRootRequest>("api/library/roots/enabled", (service, request, _) => service.SetRootEnabledAsync(request))
.WithOpenApi("FileLibrary", "启用或禁用文件库目录。")
.WithName("SetLibraryRootEnabled");
endpoints.MapPost<IFileLibraryEndpointService>("api/library/roots/delete", (service, ctx) => service.DeleteRootAsync(ctx))
endpoints.MapPost<IFileLibraryEndpointService, DeleteLibraryRootRequest>("api/library/roots/delete", (service, request, _) => service.DeleteRootAsync(request))
.WithOpenApi("FileLibrary", "删除文件库目录。")
.WithName("DeleteLibraryRoot");
endpoints.MapPost<IFileLibraryEndpointService>("api/library/roots/scan", (service, ctx) => service.ScanRootAsync(ctx))
endpoints.MapPost<IFileLibraryEndpointService, ScanLibraryRootRequest>("api/library/roots/scan", (service, request, _) => service.ScanRootAsync(request))
.WithOpenApi("FileLibrary", "立即扫描文件库目录。")
.WithName("ScanLibraryRoot");
endpoints.MapGet<IFileLibraryEndpointService>("api/files", (service, ctx) => service.SearchFilesAsync(ctx))
endpoints.MapGet<IFileLibraryEndpointService, SearchFilesRequest>("api/files", (service, request, _) => service.SearchFilesAsync(request))
.WithOpenApi("FileLibrary", "分页查询已扫描文件。")
.WithName("SearchFiles");
endpoints.MapGet<IFileLibraryEndpointService>("api/files/detail", (service, ctx) => service.GetFileAsync(ctx))
endpoints.MapGet<IFileLibraryEndpointService, FileQueryRequest>("api/files/detail", (service, request, _) => service.GetFileAsync(request))
.WithOpenApi("FileLibrary", "查询文件详情。")
.WithName("GetFileDetail");
endpoints.MapGet<IFileLibraryEndpointService>("api/files/text", (service, ctx) => service.GetTextPreviewAsync(ctx))
endpoints.MapGet<IFileLibraryEndpointService, FileQueryRequest>("api/files/text", (service, request, _) => service.GetTextPreviewAsync(request))
.WithOpenApi("FileLibrary", "预览文本文件。")
.WithName("GetTextPreview");
endpoints.MapGet("api/files/stream", GetFileStreamAsync)
.WithOpenApi("FileLibrary", "流式传输文件(支持 Range 请求)。")
.WithName("StreamManagedFile");
endpoints.MapGet<IQrCodeService>("api/qrcode", (service, ctx) => service.GenerateQrCodeAsync(ctx))
.WithOpenApi("Utility", "生成局域网访问二维码。")
.WithName("GetQrCode");
// ---- 需要鉴权的端点示例 ----
// endpoints.MapGet("api/admin/dashboard", AdminDashboardAsync)
// .WithName("AdminDashboard")
@ -101,86 +94,16 @@ namespace Avalonia_Services.Endpoints
#region
/// <summary>
/// 从数据库查询天气预报(优先数据库,回退到内存生成)。
/// </summary>
private static async Task<object?> GetWeatherForecastsAsync(ServiceEndpointContext ctx)
private static async Task<object?> GetFileStreamAsync(ServiceEndpointContext ctx)
{
var sp = ctx.Items["ServiceProvider"] as IServiceProvider;
var service = sp?.GetService(typeof(IFileStreamService)) as IFileStreamService;
if (service is null) return null;
// 尝试从数据库读取
if (sp?.GetService(typeof(AppDataContext)) is AppDataContext db)
{
var dbForecasts = await db.WeatherForecasts
.OrderByDescending(f => f.Date)
.Take(5)
.ToListAsync();
if (!int.TryParse(ctx.Query.GetValueOrDefault("id"), out var id) || id <= 0)
return null;
if (dbForecasts.Count > 0)
{
return ResponseHelper.Ok(dbForecasts, "获取天气预报成功(来自数据库)");
}
}
// 回退:内存生成(数据库为空时)
var service = sp?.GetService(typeof(WeatherForecastService)) as WeatherForecastService
?? new WeatherForecastService();
var forecasts = service.GetWeatherForecasts();
return ResponseHelper.Ok(forecasts, "获取天气预报成功(内存生成)");
}
/// <summary>
/// 从数据库获取用户信息(演示数据库查询),若无数据则返回演示用户。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>用户信息。</returns>
private static async Task<object?> GetUserFromDatabaseAsync(ServiceEndpointContext ctx)
{
var sp = ctx.Items["ServiceProvider"] as IServiceProvider;
// 尝试从数据库读取用户
if (sp?.GetService(typeof(AppDataContext)) is AppDataContext db)
{
var users = await db.Set<UserEntity>().Take(1).ToListAsync();
if (users.Count > 0)
{
return ResponseHelper.Ok(users[0], "获取用户成功(来自数据库)");
}
}
// 回退:演示数据
await Task.Delay(100);
var user = new { id = 1, name = "张三", email = "zhangsan@example.com" };
return ResponseHelper.Ok(user);
}
/// <summary>
/// 处理前端发送的数据POST 演示),将数据存入数据库或转为大写返回。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>处理结果。</returns>
private static async Task<object?> ProcessDataAsync(ServiceEndpointContext ctx)
{
var sp = ctx.Items["ServiceProvider"] as IServiceProvider;
// 演示:将收到的数据存入数据库
var input = ctx.Body ?? string.Empty;
if (sp?.GetService(typeof(AppDataContext)) is AppDataContext db && !string.IsNullOrWhiteSpace(input))
{
var forecast = new WeatherForecastEntity
{
Date = DateOnly.FromDateTime(DateTime.Now),
TemperatureC = 20,
Summary = input,
};
db.WeatherForecasts.Add(forecast);
await db.SaveChangesAsync();
return ResponseHelper.Ok(forecast, "数据已存入数据库");
}
await Task.Delay(200);
return ResponseHelper.Ok(new { input, processed = input.ToUpperInvariant() });
return await service.GetFileStreamAsync(id);
}
#endregion

View File

@ -16,17 +16,17 @@ 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 +41,17 @@ 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);
}
}

View File

@ -17,6 +17,19 @@ namespace Avalonia_Services.Services.FileLibrary
public sealed record DeleteLibraryRootRequest(
[property: JsonPropertyName("id")] int Id);
public sealed record DirectoryQueryRequest(
[property: JsonPropertyName("path")] string? Path);
public sealed record FileQueryRequest(
[property: JsonPropertyName("id")] int Id);
public sealed record SearchFilesRequest(
[property: JsonPropertyName("page")] int Page = 1,
[property: JsonPropertyName("pageSize")] int PageSize = 24,
[property: JsonPropertyName("mediaType")] string? MediaType = null,
[property: JsonPropertyName("keyword")] string? Keyword = null,
[property: JsonPropertyName("rootId")] int RootId = 0);
public sealed record DriveDto(
string Name,
string DisplayName,

View File

@ -1,99 +1,77 @@
using Avalonia_Common.Core;
using Avalonia_Services.Core;
using System.Text.Json;
namespace Avalonia_Services.Services.FileLibrary
{
public sealed class FileLibraryEndpointService(IFileLibraryService fileLibrary) : IFileLibraryEndpointService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public async Task<object?> GetDrivesAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> GetDrivesAsync(ServiceEndpointContext ctx)
{
return ResponseHelper.Ok(await fileLibrary.GetDrivesAsync());
}
public async Task<object?> GetDirectoriesAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> GetDirectoriesAsync(DirectoryQueryRequest request)
{
var path = ctx.Query.GetValueOrDefault("path");
return ResponseHelper.Ok(await fileLibrary.GetDirectoriesAsync(path));
return ResponseHelper.Ok(await fileLibrary.GetDirectoriesAsync(request.Path));
}
public async Task<object?> GetRootsAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> GetRootsAsync(ServiceEndpointContext ctx)
{
return ResponseHelper.Ok(await fileLibrary.GetRootsAsync());
}
public async Task<object?> AddRootAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> AddRootAsync(AddLibraryRootRequest request)
{
var request = ReadBody<AddLibraryRootRequest>(ctx);
return ResponseHelper.Ok(await fileLibrary.AddRootAsync(request), "文件库目录已添加并完成扫描。");
}
public async Task<object?> SetRootEnabledAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> SetRootEnabledAsync(UpdateLibraryRootRequest request)
{
var request = ReadBody<UpdateLibraryRootRequest>(ctx);
return ResponseHelper.Ok(await fileLibrary.SetRootEnabledAsync(request), "文件库目录状态已更新。");
}
public async Task<object?> DeleteRootAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> DeleteRootAsync(DeleteLibraryRootRequest request)
{
var request = ReadBody<DeleteLibraryRootRequest>(ctx);
await fileLibrary.DeleteRootAsync(request);
return ResponseHelper.Succeed("文件库目录已删除。");
}
public async Task<object?> ScanRootAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> ScanRootAsync(ScanLibraryRootRequest request)
{
var request = ReadBody<ScanLibraryRootRequest>(ctx);
return ResponseHelper.Ok(await fileLibrary.ScanRootAsync(request.Id), "文件库目录扫描完成。");
}
public async Task<object?> SearchFilesAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> SearchFilesAsync(SearchFilesRequest request)
{
return await fileLibrary.SearchFilesAsync(ctx);
return await fileLibrary.SearchFilesAsync(request);
}
public async Task<object?> GetFileAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> GetFileAsync(FileQueryRequest request)
{
var id = ReadId(ctx);
var file = await fileLibrary.GetFileAsync(id);
ValidateFileId(request.Id);
var file = await fileLibrary.GetFileAsync(request.Id);
return file is null
? ResponseHelper.Failure(404, "文件不存在或尚未扫描入库。")
: ResponseHelper.Ok(file);
}
public async Task<object?> GetTextPreviewAsync(ServiceEndpointContext ctx)
public async Task<IApiResponse> GetTextPreviewAsync(FileQueryRequest request)
{
var id = ReadId(ctx);
var preview = await fileLibrary.GetTextPreviewAsync(id);
ValidateFileId(request.Id);
var preview = await fileLibrary.GetTextPreviewAsync(request.Id);
return preview is null
? ResponseHelper.Failure(404, "文本文件不存在或无法预览。")
: ResponseHelper.Ok(preview);
}
private static T ReadBody<T>(ServiceEndpointContext ctx)
private static void ValidateFileId(int id)
{
if (string.IsNullOrWhiteSpace(ctx.Body))
if (id > 0)
{
throw new InvalidOperationException("请求体不能为空。");
return;
}
var body = JsonSerializer.Deserialize<T>(ctx.Body, JsonOptions);
return body ?? throw new InvalidOperationException("请求体格式错误。");
}
private static int ReadId(ServiceEndpointContext ctx)
{
if (int.TryParse(ctx.Query.GetValueOrDefault("id"), out var id) && id > 0)
{
return id;
}
throw new InvalidOperationException("id 参数无效。");
throw new ArgumentException("id 参数无效。");
}
}
}

View File

@ -213,13 +213,13 @@ namespace Avalonia_Services.Services.FileLibrary
}
}
public async Task<PagedResponse<FileRecordDto>> SearchFilesAsync(ServiceEndpointContext ctx, CancellationToken cancellationToken = default)
public async Task<PagedResponse<FileRecordDto>> SearchFilesAsync(SearchFilesRequest request, CancellationToken cancellationToken = default)
{
var page = ParseInt(ctx.Query.GetValueOrDefault("page"), 1, 1, 100000);
var pageSize = ParseInt(ctx.Query.GetValueOrDefault("pageSize"), 24, 1, 100);
var mediaType = ctx.Query.GetValueOrDefault("mediaType")?.Trim();
var keyword = ctx.Query.GetValueOrDefault("keyword")?.Trim();
var rootId = ParseInt(ctx.Query.GetValueOrDefault("rootId"), 0, 0, int.MaxValue);
var page = Math.Clamp(request.Page, 1, 100000);
var pageSize = Math.Clamp(request.PageSize, 1, 100);
var mediaType = request.MediaType?.Trim();
var keyword = request.Keyword?.Trim();
var rootId = Math.Clamp(request.RootId, 0, int.MaxValue);
var query = db.ManagedFileRecords
.AsNoTracking()
@ -399,11 +399,5 @@ namespace Avalonia_Services.Services.FileLibrary
MediaFileTypes.IsBrowserPlayable(file.Extension));
}
private static int ParseInt(string? value, int fallback, int min, int max)
{
return int.TryParse(value, out var parsed)
? Math.Clamp(parsed, min, max)
: fallback;
}
}
}

View File

@ -0,0 +1,37 @@
using Avalonia_EFCore.Database;
using Avalonia_EFCore.Models;
using Avalonia_Services.Core;
using Microsoft.EntityFrameworkCore;
namespace Avalonia_Services.Services.FileLibrary
{
public interface IFileStreamService
{
Task<FileStreamResponse?> GetFileStreamAsync(int id, CancellationToken cancellationToken = default);
}
public sealed class FileStreamService(AppDataContext db) : IFileStreamService
{
public async Task<FileStreamResponse?> GetFileStreamAsync(int id, CancellationToken cancellationToken = default)
{
var file = await db.ManagedFileRecords
.AsNoTracking()
.Include(item => item.LibraryRoot)
.FirstOrDefaultAsync(item =>
item.Id == id
&& item.Exists
&& item.LibraryRoot != null
&& item.LibraryRoot.IsAvailable,
cancellationToken);
if (file is null || !System.IO.File.Exists(file.AbsolutePath))
return null;
return new FileStreamResponse(
file.AbsolutePath,
file.FileName,
file.ContentType,
file.LastWriteTimeUtc);
}
}
}

View File

@ -1,27 +1,28 @@
using Avalonia_Common.Core;
using Avalonia_Services.Core;
namespace Avalonia_Services.Services.FileLibrary
{
public interface IFileLibraryEndpointService
{
Task<object?> GetDrivesAsync(ServiceEndpointContext ctx);
Task<IApiResponse> GetDrivesAsync(ServiceEndpointContext ctx);
Task<object?> GetDirectoriesAsync(ServiceEndpointContext ctx);
Task<IApiResponse> GetDirectoriesAsync(DirectoryQueryRequest request);
Task<object?> GetRootsAsync(ServiceEndpointContext ctx);
Task<IApiResponse> GetRootsAsync(ServiceEndpointContext ctx);
Task<object?> AddRootAsync(ServiceEndpointContext ctx);
Task<IApiResponse> AddRootAsync(AddLibraryRootRequest request);
Task<object?> SetRootEnabledAsync(ServiceEndpointContext ctx);
Task<IApiResponse> SetRootEnabledAsync(UpdateLibraryRootRequest request);
Task<object?> DeleteRootAsync(ServiceEndpointContext ctx);
Task<IApiResponse> DeleteRootAsync(DeleteLibraryRootRequest request);
Task<object?> ScanRootAsync(ServiceEndpointContext ctx);
Task<IApiResponse> ScanRootAsync(ScanLibraryRootRequest request);
Task<object?> SearchFilesAsync(ServiceEndpointContext ctx);
Task<IApiResponse> SearchFilesAsync(SearchFilesRequest request);
Task<object?> GetFileAsync(ServiceEndpointContext ctx);
Task<IApiResponse> GetFileAsync(FileQueryRequest request);
Task<object?> GetTextPreviewAsync(ServiceEndpointContext ctx);
Task<IApiResponse> GetTextPreviewAsync(FileQueryRequest request);
}
}

View File

@ -1,5 +1,4 @@
using Avalonia_Common.Core;
using Avalonia_Services.Core;
namespace Avalonia_Services.Services.FileLibrary
{
@ -21,7 +20,7 @@ namespace Avalonia_Services.Services.FileLibrary
Task ScanDueRootsAsync(CancellationToken cancellationToken = default);
Task<PagedResponse<FileRecordDto>> SearchFilesAsync(ServiceEndpointContext ctx, CancellationToken cancellationToken = default);
Task<PagedResponse<FileRecordDto>> SearchFilesAsync(SearchFilesRequest request, CancellationToken cancellationToken = default);
Task<FileRecordDto?> GetFileAsync(int id, CancellationToken cancellationToken = default);

View File

@ -0,0 +1,9 @@
using Avalonia_Services.Core;
namespace Avalonia_Services.Services.QrCode
{
public interface IQrCodeService
{
Task<object?> GenerateQrCodeAsync(ServiceEndpointContext ctx);
}
}

View File

@ -0,0 +1,4 @@
namespace Avalonia_Services.Services.QrCode
{
public sealed record QrCodeResponse(string Url, string QrCodeBase64);
}

View File

@ -0,0 +1,46 @@
using Avalonia_Common.Core;
using Avalonia_Services.Core;
using QRCoder;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
namespace Avalonia_Services.Services.QrCode
{
public sealed class QrCodeService : IQrCodeService
{
public Task<object?> GenerateQrCodeAsync(ServiceEndpointContext ctx)
{
var ip = GetLanIpAddress();
if (ip is null)
throw new InvalidOperationException("无法获取局域网IP地址");
var url = $"http://{ip}:5206";
var base64 = GeneratePngBase64(url);
return Task.FromResult<object?>(ResponseHelper.Ok(new QrCodeResponse(url, base64)));
}
private static string GeneratePngBase64(string content)
{
using var generator = new QRCodeGenerator();
using var data = generator.CreateQrCode(content, QRCodeGenerator.ECCLevel.Q);
using var png = new PngByteQRCode(data);
var bytes = png.GetGraphic(20);
return $"data:image/png;base64,{Convert.ToBase64String(bytes)}";
}
private static string? GetLanIpAddress()
{
return NetworkInterface.GetAllNetworkInterfaces()
.Where(ni => ni.OperationalStatus == OperationalStatus.Up
&& ni.NetworkInterfaceType != NetworkInterfaceType.Loopback)
.SelectMany(ni => ni.GetIPProperties().UnicastAddresses)
.Select(ua => ua.Address)
.FirstOrDefault(ip =>
ip.AddressFamily == AddressFamily.InterNetwork
&& !IPAddress.IsLoopback(ip)
&& !ip.ToString().StartsWith("169.254"))
?.ToString();
}
}
}

View File

@ -21,6 +21,8 @@ const total = ref(0)
const loading = ref(false)
const scanningId = ref<number | null>(null)
const errorMessage = ref('')
const showQrCode = ref(false)
const qrCodeData = ref<{ url: string; qrCodeBase64: string } | null>(null)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const availableRoots = computed(() => roots.value.filter((root) => root.isAvailable))
@ -176,6 +178,15 @@ async function refreshAll() {
await Promise.all([loadRoots(), loadFiles()])
}
async function loadQrCode() {
try {
qrCodeData.value = await api.qrCode()
showQrCode.value = true
} catch (error) {
setError(error)
}
}
onMounted(async () => {
loading.value = true
try {
@ -320,6 +331,7 @@ onMounted(async () => {
</div>
<div class="mobile-header-actions">
<button v-if="!isBrowsingRoots" type="button" class="back-button" @click="backToRoots">返回</button>
<button type="button" class="qr-button" title="生成二维码" @click="loadQrCode">二维码</button>
<a href="/admin" class="admin-link">管理</a>
</div>
</header>
@ -431,5 +443,17 @@ onMounted(async () => {
<span>{{ page }} / {{ totalPages }}</span>
<button type="button" :disabled="page >= totalPages" @click="changePage(page + 1)">下一页</button>
</nav>
<Teleport to="body">
<div v-if="showQrCode" class="qr-overlay" @click.self="showQrCode = false">
<div class="qr-modal">
<h2>扫码访问</h2>
<img v-if="qrCodeData" :src="qrCodeData.qrCodeBase64" alt="QR Code" class="qr-image" />
<p v-else class="qr-hint">加载中...</p>
<p class="qr-hint">使用手机扫描二维码即可在局域网中打开此网站</p>
<button type="button" class="primary-button qr-close" @click="showQrCode = false">关闭</button>
</div>
</div>
</Teleport>
</main>
</template>

View File

@ -1,10 +1,11 @@
// 扩展 Window 接口,声明 C# 桥接注入的全局属性
declare global {
interface Window {
interface Window {
/** 由 C# BridgeScript 注入,标记当前运行在 WebView2 环境中 */
isWebView2?: boolean
/** 由 WebView2 宿主注入,用于向 C# 发送消息 */
invokeCSharpAction?: (message: string) => void
__pcMediaOrigin?: string
}
}

View File

@ -16,6 +16,9 @@ export const apiOrigin = (): string => HTTP_ORIGIN
export const apiUrl = (path: string): string => {
if (/^https?:\/\//i.test(path)) return path
const normalized = path.startsWith('/') ? path : `/${path}`
if (isWebView2() && window.__pcMediaOrigin) {
return `${window.__pcMediaOrigin}${normalized}`
}
return `${isWebView2() ? '' : HTTP_ORIGIN}${normalized}`
}

View File

@ -90,4 +90,5 @@ export const api = {
getTextPreview: (id: number) =>
request<TextPreviewDto>(`files/text${qs({ id })}`),
mediaUrl: (path: string) => apiUrl(path),
qrCode: () => request<{ url: string; qrCodeBase64: string }>('qrcode'),
}

View File

@ -595,6 +595,59 @@ a {
text-align: center;
}
.qr-button {
border-radius: 999px;
padding: 8px 14px;
color: var(--accent-strong);
background: #fff;
}
.qr-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(16, 24, 40, 0.55);
backdrop-filter: blur(6px);
}
.qr-modal {
display: grid;
justify-items: center;
gap: 16px;
border-radius: 18px;
padding: 28px 24px 22px;
background: #fff;
box-shadow: 0 24px 60px rgba(16, 24, 40, 0.22);
text-align: center;
}
.qr-modal h2 {
margin: 0;
font-size: 20px;
font-weight: 800;
}
.qr-image {
display: block;
width: 240px;
height: 240px;
border: 1px solid var(--line);
border-radius: 12px;
}
.qr-hint {
margin: 0;
color: var(--muted);
font-size: 14px;
}
.qr-close {
min-width: 120px;
}
@media (max-width: 1100px) {
.admin-layout,
.admin-browser {

11
FileShare.slnx Normal file
View File

@ -0,0 +1,11 @@
<Solution>
<Project Path="Avalonia-API/Avalonia-API.csproj" Id="e33aba9a-a56b-4f6b-8eaa-3acbed65ebad" />
<Project Path="Avalonia-Common/Avalonia-Common.csproj" Id="caed4118-2161-4382-90b8-35fb4efe3b5f" />
<Project Path="Avalonia-EFCore/Avalonia-EFCore.csproj" Id="64557501-62a7-4863-b2bf-1570b8c6fecb" />
<Project Path="Avalonia-Services/Avalonia-Services.csproj" Id="b8757cf9-5422-4c67-acae-3c967c95f866" />
<Project Path="Avalonia-Web-VUE/avalonia-web-vue.esproj">
<Build />
<Deploy />
</Project>
<Project Path="Avalonia-PC/Avalonia-PC.csproj" />
</Solution>