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
+187 -5
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>