feat: 二维码访问功能,统一端点管道增强,端点迁移至 Services 层
- 新增二维码生成端点,自动检测局域网 IP,前端扫一扫即可打开网站 - 提取 IApiResponse 接口,ServiceRequestBinder 支持强类型请求 DTO 绑定 - FileStream 端点迁移至 AppEndpoints 统一注册,管道支持 FileStreamResponse 原始文件返回 - 文件库端点全面使用 MapGet<TService, TRequest> 泛型注册 - 移除 Avalonia-API/Extensions 中的业务端点文件,统一由 Services 层管理
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user