From 271e9714ffc0e165b29a97637eb7f13e34257a87 Mon Sep 17 00:00:00 2001 From: luoqian <2769838458@qq.com> Date: Mon, 11 May 2026 14:35:34 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E6=9E=B6=E6=9E=84=EF=BC=8C=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E7=AB=AF=E6=8E=A5=E5=8F=A3=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构项目结构,引入 Avalonia-Common、Avalonia-EFCore、Avalonia-Services,实现 API 与桌面端统一端点注册、过滤器、鉴权和标准响应格式。支持多数据库自动迁移与配置,集成 Serilog 日志系统。移除旧路由与控制器,提升接口一致性与可维护性。 --- .gitignore | 6 +- .vscode/settings.json | 6 + Avalonia-API/Avalonia-API.csproj | 7 + Avalonia-API/Avalonia-API.csproj.user | 5 +- .../Configuration/ServicesConfiguration.cs | 33 +++- .../Controllers/WeatherForecastController.cs | 20 -- .../Extensions/UnifiedEndpointExtensions.cs | 163 +++++++++++++++ Avalonia-API/Program.cs | 72 +++++-- Avalonia-API/appsettings.json | 9 +- Avalonia-API/logs/log-20260511.txt | 8 + Avalonia-Common/Avalonia-Common.csproj | 18 ++ Avalonia-Common/Core/ApiResponse.cs | 176 +++++++++++++++++ .../Infrastructure/LoggingConfiguration.cs | 124 ++++++++++++ Avalonia-EFCore/Avalonia-EFCore.csproj | 24 +++ Avalonia-EFCore/Database/AppDbContext.cs | 93 +++++++++ .../Database/DatabaseConfiguration.cs | 87 ++++++++ .../Database/DatabaseExtensions.cs | 51 +++++ Avalonia-EFCore/Database/DatabaseManager.cs | 154 +++++++++++++++ .../Database/DatabaseProviderRegistry.cs | 56 ++++++ Avalonia-PC/Avalonia-PC.csproj | 10 +- Avalonia-PC/Avalonia-PC.csproj.user | 9 + Avalonia-PC/Avalonia-PC.slnx | 2 + Avalonia-PC/Program.cs | 38 +++- Avalonia-PC/Properties/launchSettings.json | 11 ++ Avalonia-PC/Views/MainWindow.Routes.cs | 45 ++--- Avalonia-PC/Views/MainWindow.axaml.cs | 126 +++--------- Avalonia-Services/Avalonia-Services.csproj | 14 ++ Avalonia-Services/Core/EndpointPrinter.cs | 64 ++++++ .../Core/GlobalExceptionFilter.cs | 93 +++++++++ Avalonia-Services/Core/IAuthService.cs | 38 ++++ Avalonia-Services/Core/IEndpointFilter.cs | 40 ++++ .../Core/ServiceEndpointCollection.cs | 158 +++++++++++++++ .../Core/ServiceEndpointContext.cs | 79 ++++++++ Avalonia-Services/Database/AppDataContext.cs | 37 ++++ Avalonia-Services/Endpoints/AppEndpoints.cs | 140 +++++++++++++ .../Extensions/DesktopEndpointAdapter.cs | 186 ++++++++++++++++++ Avalonia-Services/Models/UserEntity.cs | 26 +++ .../Models/WeatherForecastEntity.cs | 27 +++ 38 files changed, 2073 insertions(+), 182 deletions(-) create mode 100644 .vscode/settings.json delete mode 100644 Avalonia-API/Controllers/WeatherForecastController.cs create mode 100644 Avalonia-API/Extensions/UnifiedEndpointExtensions.cs create mode 100644 Avalonia-API/logs/log-20260511.txt create mode 100644 Avalonia-Common/Avalonia-Common.csproj create mode 100644 Avalonia-Common/Core/ApiResponse.cs create mode 100644 Avalonia-Common/Infrastructure/LoggingConfiguration.cs create mode 100644 Avalonia-EFCore/Avalonia-EFCore.csproj create mode 100644 Avalonia-EFCore/Database/AppDbContext.cs create mode 100644 Avalonia-EFCore/Database/DatabaseConfiguration.cs create mode 100644 Avalonia-EFCore/Database/DatabaseExtensions.cs create mode 100644 Avalonia-EFCore/Database/DatabaseManager.cs create mode 100644 Avalonia-EFCore/Database/DatabaseProviderRegistry.cs create mode 100644 Avalonia-PC/Avalonia-PC.csproj.user create mode 100644 Avalonia-PC/Properties/launchSettings.json create mode 100644 Avalonia-Services/Core/EndpointPrinter.cs create mode 100644 Avalonia-Services/Core/GlobalExceptionFilter.cs create mode 100644 Avalonia-Services/Core/IAuthService.cs create mode 100644 Avalonia-Services/Core/IEndpointFilter.cs create mode 100644 Avalonia-Services/Core/ServiceEndpointCollection.cs create mode 100644 Avalonia-Services/Core/ServiceEndpointContext.cs create mode 100644 Avalonia-Services/Database/AppDataContext.cs create mode 100644 Avalonia-Services/Endpoints/AppEndpoints.cs create mode 100644 Avalonia-Services/Extensions/DesktopEndpointAdapter.cs create mode 100644 Avalonia-Services/Models/UserEntity.cs create mode 100644 Avalonia-Services/Models/WeatherForecastEntity.cs diff --git a/.gitignore b/.gitignore index 4918c6c..aa66bfa 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,8 @@ /avalonia-web-react/obj /avalonia-web-react/obj /avalonia-web-react/node_modules -/avalonia-web-react/dist \ No newline at end of file +/avalonia-web-react/dist +/Avalonia-EFCore/bin +/Avalonia-EFCore/obj +/Avalonia-Common/bin +/Avalonia-Common/obj diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1cb80bb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "chat.tools.terminal.autoApprove": { + "ForEach-Object": true, + "dotnet list": true + } +} \ No newline at end of file diff --git a/Avalonia-API/Avalonia-API.csproj b/Avalonia-API/Avalonia-API.csproj index 3b60685..56c79b6 100644 --- a/Avalonia-API/Avalonia-API.csproj +++ b/Avalonia-API/Avalonia-API.csproj @@ -9,10 +9,17 @@ + + + + + + + diff --git a/Avalonia-API/Avalonia-API.csproj.user b/Avalonia-API/Avalonia-API.csproj.user index 9ff5820..983ecfc 100644 --- a/Avalonia-API/Avalonia-API.csproj.user +++ b/Avalonia-API/Avalonia-API.csproj.user @@ -1,6 +1,9 @@  - https + http + + + ProjectDebugger \ No newline at end of file diff --git a/Avalonia-API/Configuration/ServicesConfiguration.cs b/Avalonia-API/Configuration/ServicesConfiguration.cs index 823bc0f..6d3a6a0 100644 --- a/Avalonia-API/Configuration/ServicesConfiguration.cs +++ b/Avalonia-API/Configuration/ServicesConfiguration.cs @@ -1,15 +1,38 @@ -using Avalonia_Services.Services; +using Avalonia_EFCore.Database; +using Avalonia_Services.Core; +using Avalonia_Services.Database; +using Avalonia_Services.Endpoints; +using Avalonia_Services.Services; +using Microsoft.Extensions.DependencyInjection; namespace Avalonia_API.Configuration { public static class ServicesConfiguration { - public static void ConfigureServices(this IServiceCollection services) + /// + /// 注册统一端点及其依赖的服务(含数据库)。 + /// 所有业务端点定义在 Avalonia-Services/Endpoints/AppEndpoints.cs。 + /// + public static IServiceCollection AddUnifiedApiServices(this IServiceCollection services) { - // Register your services here - // For example: - // services.AddSingleton(); + // ---- 数据库 ---- + // 从 appsettings.json 读取 DatabaseConfiguration 节 + // 注册默认数据库提供程序(SQLite / MySQL / PostgreSQL / SqlServer) + DatabaseProviderRegistry.RegisterDefaults(); + + // 注册 AppDataContext(共享数据上下文) + services.AddAppDatabase(DatabaseConfiguration.ForSQLite("app.db")); + + // ---- 业务服务 ---- services.AddScoped(); + + // ---- 统一端点 ---- + var endpointBuilder = new ServiceEndpointBuilder(); + AppEndpoints.Configure(endpointBuilder); + var endpoints = endpointBuilder.Build(); + services.AddSingleton(endpoints); + + return services; } } } diff --git a/Avalonia-API/Controllers/WeatherForecastController.cs b/Avalonia-API/Controllers/WeatherForecastController.cs deleted file mode 100644 index 7b83edf..0000000 --- a/Avalonia-API/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Avalonia_Services.Models; -using Avalonia_Services.Services; -using Microsoft.AspNetCore.Mvc; - -namespace Avalonia_API.Controllers -{ - [ApiController] - [Route("[controller]")] - public class WeatherForecastController(WeatherForecastService weatherForecastService) : ControllerBase - { - - private readonly WeatherForecastService _weatherForecastService = weatherForecastService; - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - return _weatherForecastService.GetWeatherForecasts(); - } - } -} diff --git a/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs b/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs new file mode 100644 index 0000000..f43482f --- /dev/null +++ b/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs @@ -0,0 +1,163 @@ +using Avalonia_Services.Core; +using AspNetCoreFilterContext = Microsoft.AspNetCore.Http.EndpointFilterInvocationContext; +using AspNetCoreFilterDelegate = Microsoft.AspNetCore.Http.EndpointFilterDelegate; +// 解决与 ASP.NET Core 同名类型的冲突 +using UnifiedFilter = Avalonia_Services.Core.IEndpointFilter; + +namespace Avalonia_API.Extensions +{ + /// + /// 将 Avalonia-Services 的统一端点映射到 ASP.NET Core Minimal API。 + /// 支持鉴权、过滤器、中间件的完整 ASP.NET Core 管道。 + /// + public static class UnifiedEndpointExtensions + { + /// + /// 将 ServiceEndpointCollection 中的所有端点注册到 ASP.NET Core 路由。 + /// + public static IEndpointRouteBuilder MapUnifiedEndpoints( + this IEndpointRouteBuilder routeBuilder, + ServiceEndpointCollection endpoints, + IServiceProvider serviceProvider) + { + var apiGroup = routeBuilder.MapGroup("/"); + + foreach (var endpoint in endpoints.Endpoints) + { + var routeHandlerBuilder = MapEndpoint(apiGroup, endpoint, serviceProvider); + + // 全局过滤器 → ASP.NET Core Endpoint Filters + foreach (var globalFilter in endpoints.GlobalFilters) + { + routeHandlerBuilder.AddEndpointFilter( + async (context, next) => await ConvertFilterAsync(globalFilter, context, next)); + } + + // 端点专属过滤器 + foreach (var filter in endpoint.Filters) + { + routeHandlerBuilder.AddEndpointFilter( + async (context, next) => await ConvertFilterAsync(filter, context, next)); + } + + // 鉴权(使用 ASP.NET Core 原生鉴权机制) + if (endpoint.RequireAuthorization) + { + if (!string.IsNullOrEmpty(endpoint.Policy)) + { + routeHandlerBuilder.RequireAuthorization(endpoint.Policy); + } + else + { + routeHandlerBuilder.RequireAuthorization(); + } + } + + if (!string.IsNullOrEmpty(endpoint.Name)) + { + routeHandlerBuilder.WithName(endpoint.Name); + } + } + + return routeBuilder; + } + + private static RouteHandlerBuilder MapEndpoint( + IEndpointRouteBuilder group, + ServiceEndpoint endpoint, + IServiceProvider serviceProvider) + { + var handler = CreateAspNetCoreHandler(endpoint.Handler, serviceProvider); + + return endpoint.HttpMethod.ToUpperInvariant() switch + { + "GET" => group.MapGet(endpoint.Pattern, handler), + "POST" => group.MapPost(endpoint.Pattern, handler), + "PUT" => group.MapPut(endpoint.Pattern, handler), + "DELETE" => group.MapDelete(endpoint.Pattern, handler), + _ => group.MapGet(endpoint.Pattern, handler), + }; + } + + private static Delegate CreateAspNetCoreHandler( + Func> unifiedHandler, + IServiceProvider serviceProvider) + { + return async (HttpContext httpContext) => + { + var ctx = await BuildContextFromHttpContext(httpContext); + ctx.Items["ServiceProvider"] = serviceProvider; + + var result = await unifiedHandler(ctx); + + // 同步响应状态 + httpContext.Response.StatusCode = ctx.StatusCode; + foreach (var kvp in ctx.ResponseHeaders) + { + httpContext.Response.Headers[kvp.Key] = kvp.Value; + } + + return result is not null ? Results.Json(result) : Results.Ok(); + }; + } + + private static async Task BuildContextFromHttpContext(HttpContext httpContext) + { + var ctx = new ServiceEndpointContext + { + Path = httpContext.Request.Path.Value ?? "/", + Method = httpContext.Request.Method, + StatusCode = 200, + }; + + foreach (var header in httpContext.Request.Headers) + { + ctx.Headers[header.Key] = header.Value.ToString(); + } + + foreach (var query in httpContext.Request.Query) + { + ctx.Query[query.Key] = query.Value.ToString(); + } + + if (httpContext.Request.ContentLength > 0) + { + using var reader = new StreamReader(httpContext.Request.Body); + ctx.Body = await reader.ReadToEndAsync(); + } + + ctx.Items["HttpContext"] = httpContext; + + return ctx; + } + + private static async ValueTask ConvertFilterAsync( + UnifiedFilter unifiedFilter, + AspNetCoreFilterContext aspContext, + AspNetCoreFilterDelegate aspNext) + { + var httpContext = aspContext.HttpContext; + var ctx = httpContext.Items["UnifiedContext"] as ServiceEndpointContext + ?? await BuildContextFromHttpContext(httpContext); + + httpContext.Items["UnifiedContext"] = ctx; + + await unifiedFilter.InvokeAsync(ctx, async (c) => + { + httpContext.Response.StatusCode = c.StatusCode; + foreach (var kvp in c.ResponseHeaders) + { + httpContext.Response.Headers[kvp.Key] = kvp.Value; + } + await aspNext(aspContext); + }); + + if (ctx.ResponseBody is not null) + { + return Results.Json(ctx.ResponseBody, statusCode: ctx.StatusCode); + } + + return null!; + } + } +} diff --git a/Avalonia-API/Program.cs b/Avalonia-API/Program.cs index 666a9c5..e6c539a 100644 --- a/Avalonia-API/Program.cs +++ b/Avalonia-API/Program.cs @@ -1,23 +1,57 @@ -var builder = WebApplication.CreateBuilder(args); +using Avalonia_API.Configuration; +using Avalonia_API.Extensions; +using Avalonia_Common.Infrastructure; +using Avalonia_EFCore.Database; +using Avalonia_Services.Core; +using Avalonia_Services.Database; +using Serilog; -// Add services to the container. +// 初始化日志系统 +Log.Logger = LoggingConfiguration.CreateDefaultLogger(logDir: "logs"); +Log.Information("Avalonia-API 正在启动..."); -builder.Services.AddControllers(); -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) +try { - app.MapOpenApi(); + var builder = WebApplication.CreateBuilder(args); + + // 使用 Serilog 作为日志提供程序 + builder.Host.UseSerilog(); + + // Add services to the container. + builder.Services.AddControllers(); + builder.Services.AddOpenApi(); + + // 注册统一端点及业务服务(入口在 Avalonia-Services/Endpoints/AppEndpoints.cs) + builder.Services.AddUnifiedApiServices(); + + var app = builder.Build(); + + // 初始化数据库(自动迁移 + 种子数据) + app.Services.InitializeDatabase(); + + // 启动时打印所有接口 + var endpoints = app.Services.GetRequiredService(); + EndpointPrinter.PrintEndpoints(endpoints, "Avalonia-API 接口列表"); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + } + + app.UseHttpsRedirection(); + app.UseAuthorization(); + + // 将统一端点映射到 ASP.NET Core 路由 + app.MapUnifiedEndpoints(endpoints, app.Services); + + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Avalonia-API 启动失败"); +} +finally +{ + Log.CloseAndFlush(); } - -app.UseHttpsRedirection(); - -app.UseAuthorization(); - -app.MapControllers(); - -app.Run(); diff --git a/Avalonia-API/appsettings.json b/Avalonia-API/appsettings.json index 10f68b8..3b39ebc 100644 --- a/Avalonia-API/appsettings.json +++ b/Avalonia-API/appsettings.json @@ -5,5 +5,12 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "DatabaseConfiguration": { + "Provider": "SQLite", + "ConnectionString": "Data Source=avalonia-api.db", + "AutoMigrate": true, + "EnableDetailedLog": false, + "Timeout": 30 + } } diff --git a/Avalonia-API/logs/log-20260511.txt b/Avalonia-API/logs/log-20260511.txt new file mode 100644 index 0000000..71a0575 --- /dev/null +++ b/Avalonia-API/logs/log-20260511.txt @@ -0,0 +1,8 @@ +2026-05-11 13:39:27.320 [INF] Avalonia-API 正在启动... +2026-05-11 13:39:58.813 [INF] Avalonia-API 正在启动... +2026-05-11 13:40:13.058 [INF] Avalonia-API 正在启动... +2026-05-11 13:40:13.566 [INF] Now listening on: http://localhost:5206 +2026-05-11 13:40:13.603 [INF] No action descriptors found. This may indicate an incorrectly configured application or missing application parts. To learn more, visit https://aka.ms/aspnet/mvc/app-parts +2026-05-11 13:40:13.633 [INF] Application started. Press Ctrl+C to shut down. +2026-05-11 13:40:13.635 [INF] Hosting environment: Development +2026-05-11 13:40:13.635 [INF] Content root path: D:\QiChengProject\Avalonia-Stack\Avalonia-API diff --git a/Avalonia-Common/Avalonia-Common.csproj b/Avalonia-Common/Avalonia-Common.csproj new file mode 100644 index 0000000..583405c --- /dev/null +++ b/Avalonia-Common/Avalonia-Common.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + Avalonia_Common + enable + enable + + + + + + + + + + + diff --git a/Avalonia-Common/Core/ApiResponse.cs b/Avalonia-Common/Core/ApiResponse.cs new file mode 100644 index 0000000..3076c19 --- /dev/null +++ b/Avalonia-Common/Core/ApiResponse.cs @@ -0,0 +1,176 @@ +using System.Text.Json.Serialization; + +namespace Avalonia_Common.Core +{ + /// + /// 统一 API 返回格式。 + /// 所有接口的返回都包装为此格式,确保前端收到一致的数据结构。 + /// + /// 业务数据类型 + public class ApiResponse + { + /// 是否成功 + [JsonPropertyName("success")] + public bool Success { get; set; } + + /// HTTP 状态码 + [JsonPropertyName("code")] + public int Code { get; set; } + + /// 消息(成功时可为 null,失败时包含错误描述) + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// 业务数据 + [JsonPropertyName("data")] + public T? Data { get; set; } + + /// 时间戳 + [JsonPropertyName("timestamp")] + public DateTime Timestamp { get; set; } = DateTime.Now; + + /// 请求追踪 ID(用于排查问题) + [JsonPropertyName("traceId")] + public string? TraceId { get; set; } + + // ---- 快捷工厂方法 ---- + + /// 成功返回(有数据) + public static ApiResponse Ok(T data, string? message = null) + { + return new ApiResponse + { + Success = true, + Code = 200, + Message = message, + Data = data, + }; + } + + /// 失败返回 + public static ApiResponse Fail(int code, string message, T? data = default) + { + return new ApiResponse + { + Success = false, + Code = code, + Message = message, + Data = data, + }; + } + + /// 400 参数错误 + public static ApiResponse BadRequest(string message = "参数错误") + => Fail(400, message); + + /// 401 未授权 + public static ApiResponse Unauthorized(string message = "未授权") + => Fail(401, message); + + /// 403 无权限 + public static ApiResponse Forbidden(string message = "无权限") + => Fail(403, message); + + /// 404 未找到 + public static ApiResponse NotFound(string message = "资源不存在") + => Fail(404, message); + + /// 500 服务器内部错误 + public static ApiResponse ServerError(string message = "服务器内部错误") + => Fail(500, message); + } + + /// + /// 无数据的统一返回格式(object? 版本)。 + /// + public class ApiResponse : ApiResponse + { + /// 成功返回(无数据) + public static ApiResponse Succeed(string? message = null) + { + return new ApiResponse + { + Success = true, + Code = 200, + Message = message, + Data = null, + }; + } + + /// 失败返回 + public static ApiResponse Failure(int code, string message) + { + return new ApiResponse + { + Success = false, + Code = code, + Message = message, + Data = null, + }; + } + } + + /// + /// 分页返回格式 + /// + public class PagedResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } = true; + + [JsonPropertyName("code")] + public int Code { get; set; } = 200; + + [JsonPropertyName("items")] + public List Items { get; set; } = new(); + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("page")] + public int Page { get; set; } = 1; + + [JsonPropertyName("pageSize")] + public int PageSize { get; set; } = 20; + + [JsonPropertyName("totalPages")] + public int TotalPages => PageSize > 0 ? (int)Math.Ceiling((double)Total / PageSize) : 0; + + public static PagedResponse From(List items, int total, int page, int pageSize) + { + return new PagedResponse + { + Items = items, + Total = total, + Page = page, + PageSize = pageSize, + }; + } + } + + /// + /// 端点返回辅助方法 —— 在 AppEndpoints 中快捷构建统一响应。 + /// + public static class ResponseHelper + { + /// 成功返回 + public static ApiResponse Ok(T data, string? message = null) + => ApiResponse.Ok(data, message); + + /// 成功返回(无数据) + public static ApiResponse Succeed(string? message = null) + => ApiResponse.Succeed(message); + + /// 失败返回 + public static ApiResponse Fail(int code, string message, T? data = default) + => ApiResponse.Fail(code, message, data); + + /// 失败返回(无数据) + public static ApiResponse Failure(int code, string message) + => ApiResponse.Failure(code, message); + + /// 分页返回 + public static PagedResponse Paged(List items, int total, int page, int pageSize) + => PagedResponse.From(items, total, page, pageSize); + } +} diff --git a/Avalonia-Common/Infrastructure/LoggingConfiguration.cs b/Avalonia-Common/Infrastructure/LoggingConfiguration.cs new file mode 100644 index 0000000..3f5c3d0 --- /dev/null +++ b/Avalonia-Common/Infrastructure/LoggingConfiguration.cs @@ -0,0 +1,124 @@ +using Serilog; +using Serilog.Events; + +namespace Avalonia_Common.Infrastructure +{ + /// + /// Serilog 日志配置 —— 可在 Avalonia-API 和 Avalonia-PC 中共享。 + /// + public static class LoggingConfiguration + { + /// + /// 默认日志目录 + /// + private static readonly string DefaultLogDir = Path.Combine(AppContext.BaseDirectory, "logs"); + + /// + /// 创建控制台日志记录器(开发环境)。 + /// + public static ILogger CreateConsoleLogger( + LogEventLevel minimumLevel = LogEventLevel.Debug) + { + return new LoggerConfiguration() + .MinimumLevel.Is(minimumLevel) + .Enrich.FromLogContext() + .WriteTo.Console( + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + } + + /// + /// 创建控制台 + 文件日志记录器。 + /// + /// 最低日志级别 + /// 日志目录,默认 ./logs + /// 保留天数 + public static ILogger CreateDefaultLogger( + LogEventLevel minimumLevel = LogEventLevel.Information, + string? logDir = null, + int retainedDays = 30) + { + logDir ??= DefaultLogDir; + + return new LoggerConfiguration() + .MinimumLevel.Is(minimumLevel) + //.MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + //.MinimumLevel.Override("System", LogEventLevel.Warning) + .Enrich.FromLogContext() + .Enrich.WithMachineName() + .Enrich.WithThreadId() + .WriteTo.Console( + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .WriteTo.File( + path: Path.Combine(logDir, "log-.txt"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: retainedDays, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}", + encoding: System.Text.Encoding.UTF8) + .CreateLogger(); + } + + /// + /// 创建只写文件的日志记录器(桌面应用静默模式)。 + /// + public static ILogger CreateFileOnlyLogger( + LogEventLevel minimumLevel = LogEventLevel.Information, + string? logDir = null, + int retainedDays = 30) + { + logDir ??= DefaultLogDir; + + return new LoggerConfiguration() + .MinimumLevel.Is(minimumLevel) + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.File( + path: Path.Combine(logDir, "app-.txt"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: retainedDays, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}", + encoding: System.Text.Encoding.UTF8) + .CreateLogger(); + } + } + + /// + /// 静态日志访问器 —— 全局静态入口,方便在没有 DI 的场景下使用。 + /// + public static class AppLog + { + private static ILogger? _logger; + + /// + /// 初始化全局日志记录器。 + /// + public static void Initialize(ILogger logger) + { + _logger = logger; + Log.Logger = logger; + } + + public static ILogger Logger => _logger ?? Log.Logger; + + public static void Debug(string messageTemplate, params object?[] propertyValues) + => Logger.Debug(messageTemplate, propertyValues); + + public static void Information(string messageTemplate, params object?[] propertyValues) + => Logger.Information(messageTemplate, propertyValues); + + public static void Warning(string messageTemplate, params object?[] propertyValues) + => Logger.Warning(messageTemplate, propertyValues); + + public static void Error(string messageTemplate, params object?[] propertyValues) + => Logger.Error(messageTemplate, propertyValues); + + public static void Error(Exception exception, string messageTemplate, params object?[] propertyValues) + => Logger.Error(exception, messageTemplate, propertyValues); + + public static void Fatal(string messageTemplate, params object?[] propertyValues) + => Logger.Fatal(messageTemplate, propertyValues); + + public static void Fatal(Exception exception, string messageTemplate, params object?[] propertyValues) + => Logger.Fatal(exception, messageTemplate, propertyValues); + } +} diff --git a/Avalonia-EFCore/Avalonia-EFCore.csproj b/Avalonia-EFCore/Avalonia-EFCore.csproj new file mode 100644 index 0000000..7c05d0f --- /dev/null +++ b/Avalonia-EFCore/Avalonia-EFCore.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + Avalonia_EFCore + enable + enable + + + + + + + + + + + + + + + + + diff --git a/Avalonia-EFCore/Database/AppDbContext.cs b/Avalonia-EFCore/Database/AppDbContext.cs new file mode 100644 index 0000000..c81ff66 --- /dev/null +++ b/Avalonia-EFCore/Database/AppDbContext.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Avalonia_EFCore.Database +{ + /// + /// 应用数据库上下文基类 —— 自动根据 DatabaseConfiguration 选择数据库提供程序。 + /// 所有业务 DbContext 继承此类即可获得多数据库支持。 + /// + public abstract class AppDbContext(DatabaseConfiguration dbConfig) : DbContext + { + private readonly DatabaseConfiguration _dbConfig = dbConfig; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (optionsBuilder.IsConfigured) return; + + ConfigureProvider(optionsBuilder, _dbConfig); + + if (_dbConfig.EnableDetailedLog) + { + optionsBuilder.LogTo(Console.WriteLine, Microsoft.Extensions.Logging.LogLevel.Information); + } + + // 启用详细的 EF Core 错误信息 + optionsBuilder.EnableDetailedErrors(); + optionsBuilder.EnableSensitiveDataLogging(_dbConfig.EnableDetailedLog); + } + + /// + /// 根据配置选择数据库提供程序。 + /// 使用注册模式,由宿主项目注册具体的提供程序实现。 + /// + public static void ConfigureProvider(DbContextOptionsBuilder optionsBuilder, DatabaseConfiguration config) + { + if (DatabaseProviderRegistry.TryGet(config.Provider, out var configurator)) + { + configurator(optionsBuilder, config.ConnectionString, config.Timeout); + } + else + { + throw new NotSupportedException( + $"数据库提供程序 {config.Provider} 未注册。" + + $"请在宿主项目中安装对应的 EF Core NuGet 包并调用 DatabaseProviderRegistry.Register()。"); + } + + optionsBuilder.EnableDetailedErrors(); + } + + /// + /// 保存时自动设置时间戳。 + /// + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + SetTimestamps(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + { + SetTimestamps(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + + private void SetTimestamps() + { + var entries = ChangeTracker.Entries() + .Where(e => e.State == EntityState.Added || e.State == EntityState.Modified); + + foreach (var entry in entries) + { + var entity = entry.Entity; + + // 使用反射设置 CreatedAt / UpdatedAt(如果存在) + var createdAtProp = entity.GetType().GetProperty("CreatedAt"); + var updatedAtProp = entity.GetType().GetProperty("UpdatedAt"); + + if (entry.State == EntityState.Added && createdAtProp != null) + { + createdAtProp.SetValue(entity, DateTime.UtcNow); + } + + if (updatedAtProp != null) + { + updatedAtProp.SetValue(entity, DateTime.UtcNow); + } + } + } + } +} diff --git a/Avalonia-EFCore/Database/DatabaseConfiguration.cs b/Avalonia-EFCore/Database/DatabaseConfiguration.cs new file mode 100644 index 0000000..e54b448 --- /dev/null +++ b/Avalonia-EFCore/Database/DatabaseConfiguration.cs @@ -0,0 +1,87 @@ +namespace Avalonia_EFCore.Database +{ + /// + /// 支持的数据库提供程序类型。 + /// + public enum DatabaseProvider + { + /// SQLite(本地文件数据库,无需安装,跨平台) + SQLite, + + /// MySQL / MariaDB + MySQL, + + /// PostgreSQL + PostgreSQL, + + /// SQL Server + SqlServer + } + + /// + /// 数据库连接配置 —— 在 appsettings.json 中配置。 + /// + public class DatabaseConfiguration + { + /// 数据库提供程序 + public DatabaseProvider Provider { get; set; } = DatabaseProvider.SQLite; + + /// 连接字符串 + public string ConnectionString { get; set; } = "Data Source=app.db"; + + /// 是否在启动时自动执行迁移 + public bool AutoMigrate { get; set; } = true; + + /// 是否启用详细日志(会打印 SQL 语句) + public bool EnableDetailedLog { get; set; } = false; + + /// 连接超时(秒) + public int Timeout { get; set; } = 30; + + // ---- 快捷构建方法 ---- + + /// SQLite 本地数据库 + public static DatabaseConfiguration ForSQLite(string dataSource = "app.db") + { + return new DatabaseConfiguration + { + Provider = DatabaseProvider.SQLite, + ConnectionString = $"Data Source={dataSource}", + AutoMigrate = true, + }; + } + + /// MySQL 数据库 + public static DatabaseConfiguration ForMySQL(string server, string database, string user, string password, uint port = 3306) + { + return new DatabaseConfiguration + { + Provider = DatabaseProvider.MySQL, + ConnectionString = $"Server={server};Port={port};Database={database};User={user};Password={password};", + }; + } + + /// PostgreSQL 数据库 + public static DatabaseConfiguration ForPostgreSQL(string host, string database, string username, string password, int port = 5432) + { + return new DatabaseConfiguration + { + Provider = DatabaseProvider.PostgreSQL, + ConnectionString = $"Host={host};Port={port};Database={database};Username={username};Password={password};", + }; + } + + /// SQL Server 数据库 + public static DatabaseConfiguration ForSqlServer(string server, string database, string? user = null, string? password = null) + { + var connStr = string.IsNullOrEmpty(user) + ? $"Server={server};Database={database};Trusted_Connection=True;TrustServerCertificate=True;" + : $"Server={server};Database={database};User Id={user};Password={password};TrustServerCertificate=True;"; + return new DatabaseConfiguration + { + Provider = DatabaseProvider.SqlServer, + ConnectionString = connStr, + }; + } + } +} diff --git a/Avalonia-EFCore/Database/DatabaseExtensions.cs b/Avalonia-EFCore/Database/DatabaseExtensions.cs new file mode 100644 index 0000000..e67373a --- /dev/null +++ b/Avalonia-EFCore/Database/DatabaseExtensions.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Avalonia_EFCore.Database +{ + /// + /// 数据库服务注册扩展 —— 在 Program.cs 中一行配置数据库。 + /// + public static class DatabaseExtensions + { + /// + /// 注册数据库上下文及相关服务。 + /// + /// 继承自 AppDbContext 的业务 DbContext + public static IServiceCollection AddAppDatabase( + this IServiceCollection services, + DatabaseConfiguration config) + where TContext : AppDbContext + { + // 注册配置 + services.AddSingleton(config); + + // 注册 DbContext + services.AddDbContext(options => + { + AppDbContext.ConfigureProvider(options, config); + }); + + // 注册数据库管理器 + services.AddScoped>(); + + return services; + } + + /// + /// 初始化数据库(在应用启动时调用一次)。 + /// + public static IServiceProvider InitializeDatabase( + this IServiceProvider serviceProvider, + Action? seeder = null) + where TContext : AppDbContext + { + using var scope = serviceProvider.CreateScope(); + var dbManager = scope.ServiceProvider.GetRequiredService>(); + + // 同步等待初始化(启动时阻塞) + dbManager.InitializeAsync(seeder).GetAwaiter().GetResult(); + + return serviceProvider; + } + } +} diff --git a/Avalonia-EFCore/Database/DatabaseManager.cs b/Avalonia-EFCore/Database/DatabaseManager.cs new file mode 100644 index 0000000..8ce4170 --- /dev/null +++ b/Avalonia-EFCore/Database/DatabaseManager.cs @@ -0,0 +1,154 @@ +using Avalonia_Common.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Avalonia_EFCore.Database +{ + /// + /// 数据库管理器 —— 负责连接测试、自动迁移、种子数据、版本检查。 + /// 在应用启动时调用,确保数据库结构与应用代码同步。 + /// + public class DatabaseManager where TContext : AppDbContext + { + private readonly TContext _context; + private readonly DatabaseConfiguration _config; + private readonly IServiceProvider? _serviceProvider; + + public DatabaseManager(TContext context, DatabaseConfiguration config, IServiceProvider? serviceProvider = null) + { + _context = context; + _config = config; + _serviceProvider = serviceProvider; + } + + /// + /// 初始化数据库:测试连接 → 自动迁移 → 种子数据。 + /// + public async Task InitializeAsync(Action? seeder = null) + { + // 1. 测试数据库连接 + var canConnect = await CanConnectAsync(); + if (!canConnect) + { + throw new InvalidOperationException( + $"无法连接到数据库 [{_config.Provider}],请检查连接字符串和数据库服务状态。"); + } + + // 2. 自动迁移(如果启用) + if (_config.AutoMigrate) + { + await MigrateAsync(); + } + + // 3. 种子数据 + if (seeder != null) + { + seeder(_context, _serviceProvider); + await _context.SaveChangesAsync(); + } + } + + /// + /// 测试数据库连接是否正常。 + /// + public async Task CanConnectAsync() + { + try + { + return await _context.Database.CanConnectAsync(); + } + catch + { + return false; + } + } + + /// + /// 执行待处理的迁移。 + /// 使用 EF Core 原生迁移机制,自动检测并应用 Schema 变更。 + /// + public async Task MigrateAsync() + { + try + { + var pendingMigrations = await _context.Database.GetPendingMigrationsAsync(); + + if (pendingMigrations.Any()) + { + AppLog.Information( + "检测到 {Count} 个待执行的数据库迁移: {Migrations}", + pendingMigrations.Count(), + string.Join(", ", pendingMigrations)); + + await _context.Database.MigrateAsync(); + + AppLog.Information("数据库迁移完成({Count} 个迁移已应用)", pendingMigrations.Count()); + } + else + { + AppLog.Information("数据库已是最新版本,无需迁移"); + } + } + catch (Exception ex) + { + AppLog.Error(ex, "数据库迁移失败"); + throw; + } + } + + /// + /// 获取数据库当前版本信息。 + /// + public async Task GetVersionInfoAsync() + { + var appliedMigrations = await _context.Database.GetAppliedMigrationsAsync(); + var pendingMigrations = await _context.Database.GetPendingMigrationsAsync(); + + return new DatabaseVersionInfo + { + Provider = _config.Provider.ToString(), + AppliedMigrations = appliedMigrations.ToList(), + PendingMigrations = pendingMigrations.ToList(), + IsLatest = !pendingMigrations.Any(), + CanConnect = await CanConnectAsync(), + }; + } + + /// + /// 生成从指定迁移到最新版本的 SQL 脚本(用于生产环境审计)。 + /// + public string GenerateMigrationScript(string? fromMigration = null) + { + var migrator = _context.GetService(); + return fromMigration is null + ? migrator.GenerateScript() + : migrator.GenerateScript(fromMigration); + } + + /// + /// 确保数据库已创建(不执行迁移,适用于简单场景)。 + /// + public bool EnsureCreated() + { + return _context.Database.EnsureCreated(); + } + } + + /// + /// 数据库版本信息 DTO。 + /// + public class DatabaseVersionInfo + { + public string Provider { get; set; } = string.Empty; + public List AppliedMigrations { get; set; } = new(); + public List PendingMigrations { get; set; } = new(); + public bool IsLatest { get; set; } + public bool CanConnect { get; set; } + } +} diff --git a/Avalonia-EFCore/Database/DatabaseProviderRegistry.cs b/Avalonia-EFCore/Database/DatabaseProviderRegistry.cs new file mode 100644 index 0000000..11520e0 --- /dev/null +++ b/Avalonia-EFCore/Database/DatabaseProviderRegistry.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; + +namespace Avalonia_EFCore.Database +{ + /// + /// 数据库提供程序注册表 —— 统一注册所有支持的提供程序配置委托。 + /// 具体使用哪个提供程序由各宿主项目决定: + /// Avalonia-API:从 appsettings.json 的 DatabaseConfiguration 节读取; + /// Avalonia-PC :固定使用 SQLite。 + /// + public static class DatabaseProviderRegistry + { + /// + /// 提供程序配置委托:optionsBuilder, connectionString, timeout → void + /// + public delegate void ProviderConfigurator(DbContextOptionsBuilder optionsBuilder, string connectionString, int timeout); + + private static readonly Dictionary _providers = new(); + + /// + /// 注册一个数据库提供程序。 + /// + public static void Register(DatabaseProvider provider, ProviderConfigurator configurator) + { + _providers[provider] = configurator; + } + + /// + /// 尝试获取注册的提供程序配置。 + /// + public static bool TryGet(DatabaseProvider provider, out ProviderConfigurator configurator) + { + return _providers.TryGetValue(provider, out configurator!); + } + + /// + /// 注册所有内置提供程序的默认配置(四个包均已内置在 Avalonia-EFCore 中)。 + /// 注册完成后由调用方根据自身需求选择具体的 。 + /// + public static void RegisterDefaults() + { + Register(DatabaseProvider.SQLite, (opts, cs, timeout) => + opts.UseSqlite(cs, o => o.CommandTimeout(timeout))); + + Register(DatabaseProvider.SqlServer, (opts, cs, timeout) => + opts.UseSqlServer(cs, o => { o.CommandTimeout(timeout); o.EnableRetryOnFailure(3); })); + + Register(DatabaseProvider.PostgreSQL, (opts, cs, timeout) => + opts.UseNpgsql(cs, o => { o.CommandTimeout(timeout); o.EnableRetryOnFailure(3); })); + + Register(DatabaseProvider.MySQL, (opts, cs, timeout) => + opts.UseMySql(cs, ServerVersion.AutoDetect(cs), o => { o.CommandTimeout(timeout); o.EnableRetryOnFailure(3); })); + } + } +} + diff --git a/Avalonia-PC/Avalonia-PC.csproj b/Avalonia-PC/Avalonia-PC.csproj index 1fdf15b..e596ba2 100644 --- a/Avalonia-PC/Avalonia-PC.csproj +++ b/Avalonia-PC/Avalonia-PC.csproj @@ -8,13 +8,19 @@ - PreserveNewest + + + + + + + @@ -35,5 +41,7 @@ + + diff --git a/Avalonia-PC/Avalonia-PC.csproj.user b/Avalonia-PC/Avalonia-PC.csproj.user new file mode 100644 index 0000000..bc9c889 --- /dev/null +++ b/Avalonia-PC/Avalonia-PC.csproj.user @@ -0,0 +1,9 @@ + + + + ProjectDebugger + + + Avalonia-PC + + \ No newline at end of file diff --git a/Avalonia-PC/Avalonia-PC.slnx b/Avalonia-PC/Avalonia-PC.slnx index c89413e..597c0de 100644 --- a/Avalonia-PC/Avalonia-PC.slnx +++ b/Avalonia-PC/Avalonia-PC.slnx @@ -1,5 +1,7 @@ + + diff --git a/Avalonia-PC/Program.cs b/Avalonia-PC/Program.cs index 7c2481b..c4e3444 100644 --- a/Avalonia-PC/Program.cs +++ b/Avalonia-PC/Program.cs @@ -1,7 +1,13 @@ using Avalonia; +using Avalonia_Common.Infrastructure; +using Avalonia_EFCore.Database; using Avalonia_PC.Views; +using Avalonia_Services.Core; +using Avalonia_Services.Database; +using Avalonia_Services.Endpoints; using Avalonia_Services.Services; using Microsoft.Extensions.DependencyInjection; +using Serilog; using System; namespace Avalonia_PC @@ -10,14 +16,23 @@ namespace Avalonia_PC { public static IServiceProvider Services { get; private set; } = null!; - // Initialization code. Don't use any Avalonia, third-party APIs or any - // SynchronizationContext-reliant code before AppMain is called: things aren't initialized - // yet and stuff might break. [STAThread] public static void Main(string[] args) { + // 初始化日志系统 + AppLog.Initialize(LoggingConfiguration.CreateDefaultLogger(logDir: "logs")); + + AppLog.Information("Avalonia-PC 正在启动..."); + ConfigureServices(); + // 初始化数据库(自动迁移 + 种子数据) + Services.InitializeDatabase(); + + // 启动时打印所有拦截的接口 + var endpoints = Services.GetRequiredService(); + EndpointPrinter.PrintEndpoints(endpoints, "Avalonia-PC 拦截接口列表"); + #if DEBUG // 开启 WebView2 远程调试,启动后在 Edge 中访问 edge://inspect 调试网页 Environment.SetEnvironmentVariable( @@ -30,7 +45,24 @@ namespace Avalonia_PC private static void ConfigureServices() { var services = new ServiceCollection(); + + // ---- 数据库 ---- + // 注册默认数据库提供程序(SQLite / MySQL / PostgreSQL / SqlServer) + DatabaseProviderRegistry.RegisterDefaults(); + + // 桌面端固定使用 SQLite 本地数据库 + services.AddAppDatabase(DatabaseConfiguration.ForSQLite("app.db")); + + // ---- 业务服务 ---- services.AddSingleton(); + + // ---- 统一端点 ---- + var endpointBuilder = new ServiceEndpointBuilder(); + AppEndpoints.Configure(endpointBuilder); + var endpoints = endpointBuilder.Build(); + services.AddSingleton(endpoints); + + // 注册 Window services.AddTransient(sp => new MainWindow(sp)); Services = services.BuildServiceProvider(); diff --git a/Avalonia-PC/Properties/launchSettings.json b/Avalonia-PC/Properties/launchSettings.json new file mode 100644 index 0000000..05300e7 --- /dev/null +++ b/Avalonia-PC/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Avalonia-PC": { + "commandName": "Project" + }, + "WSL": { + "commandName": "WSL2", + "distributionName": "" + } + } +} \ No newline at end of file diff --git a/Avalonia-PC/Views/MainWindow.Routes.cs b/Avalonia-PC/Views/MainWindow.Routes.cs index d658272..984d04e 100644 --- a/Avalonia-PC/Views/MainWindow.Routes.cs +++ b/Avalonia-PC/Views/MainWindow.Routes.cs @@ -1,3 +1,6 @@ +using Avalonia_Services.Core; +using Avalonia_Services.Endpoints; +using Avalonia_Services.Extensions; using Avalonia_Services.Services; using Microsoft.Extensions.DependencyInjection; using System; @@ -8,42 +11,22 @@ namespace Avalonia_PC.Views { public partial class MainWindow { - // 路由表:key = 接口路径(忽略大小写),value = 处理方法 - // 新增接口:在此方法中添加一行 _routes["api/xxx"] = ctx => ... - private Dictionary>> _routes = []; + /// + /// 统一端点适配器(替代原来的 _routes 字典)。 + /// 所有端点在 Avalonia-Services/AppEndpoints.cs 中统一定义。 + /// + private DesktopEndpointAdapter _endpointAdapter = null!; - // 服务容器,通过构造函数注入,路由注册时按需解析服务 + /// + /// 服务容器,通过构造函数注入。 + /// private IServiceProvider _services = null!; private void RegisterRoutes() { - var weather = _services.GetRequiredService(); - // 新增服务示例:var myService = _services.GetRequiredService(); - - _routes = new Dictionary>>(StringComparer.OrdinalIgnoreCase) - { - ["api/getUser"] = _ => GetUserFromDatabaseAsync(), - ["api/processData"] = ctx => ProcessDataAsync(ExtractInput(ctx)), - ["api/wData"] = _ => Task.FromResult(weather.GetWeatherForecasts()), - }; - } - - /// - /// 示例:模拟读取用户数据。 - /// - private static async Task GetUserFromDatabaseAsync() - { - await Task.Delay(100); - return new { id = 1, name = "张三", email = "zhangsan@example.com" }; - } - - /// - /// 示例:模拟处理输入数据。 - /// - private static async Task ProcessDataAsync(string? input) - { - await Task.Delay(200); - return $"Processed: {input?.ToUpperInvariant()}"; + // 从 DI 获取已构建的端点集合 + var endpointCollection = _services.GetRequiredService(); + _endpointAdapter = endpointCollection.CreateAdapter(_services); } } } diff --git a/Avalonia-PC/Views/MainWindow.axaml.cs b/Avalonia-PC/Views/MainWindow.axaml.cs index 9cf0978..7030598 100644 --- a/Avalonia-PC/Views/MainWindow.axaml.cs +++ b/Avalonia-PC/Views/MainWindow.axaml.cs @@ -236,7 +236,7 @@ namespace Avalonia_PC.Views } /// - /// 统一请求处理:构建上下文、处理 OPTIONS、按前缀分发并封装标准响应。 + /// 统一请求处理:构建上下文、处理 OPTIONS、使用统一端点适配器分发。 /// private async Task HandleAppRequestAsync( string? id, @@ -257,7 +257,6 @@ namespace Avalonia_PC.Views try { var uri = new Uri(rawUrl ?? throw new InvalidOperationException("请求地址不能为空。")); - var requestContext = CreateRouteRequestContext(uri, body); if (string.Equals(method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) { @@ -267,12 +266,25 @@ namespace Avalonia_PC.Views return response; } - var routeResult = await DispatchByPrefixAsync(requestContext); + // 使用统一端点适配器处理请求 + var (normalizedPath, queryParams) = ParseRequestUri(uri); + + var routeResult = await _endpointAdapter.HandleRequestAsync( + path: normalizedPath, + method: method ?? "GET", + body: body, + headers: headers, + query: queryParams); + if (routeResult.IsMatched) { response.StatusCode = routeResult.StatusCode; response.StatusMessage = routeResult.StatusMessage; response.Body = BuildSuccessResponseBody(routeResult.Data); + foreach (var kvp in routeResult.ResponseHeaders) + { + response.Headers[kvp.Key] = kvp.Value; + } return response; } @@ -291,31 +303,9 @@ namespace Avalonia_PC.Views } /// - /// 按路由表匹配并调用对应处理器。 + /// 从 URI 解析规范化路径和查询参数(供统一端点适配器使用)。 /// - private async Task DispatchByPrefixAsync(RouteRequestContext requestContext) - { - if (_routes.TryGetValue(requestContext.NormalizedPath, out var handler)) - { - var data = await handler(requestContext); - return RouteDispatchResult.Success(data); - } - - return RouteDispatchResult.NotMatched(); - } - - /// - /// 统一构建成功响应体,保持前后端响应结构一致。 - /// - private static string BuildSuccessResponseBody(object? data) - { - return JsonSerializer.Serialize(new { success = true, data }); - } - - /// - /// 从 URI 解析路径段、查询参数和 body,构建路由上下文。 - /// - private static RouteRequestContext CreateRouteRequestContext(Uri uri, string? body) + private static (string normalizedPath, Dictionary query) ParseRequestUri(Uri uri) { var host = uri.Host ?? string.Empty; var absolutePath = uri.AbsolutePath ?? string.Empty; @@ -329,13 +319,15 @@ namespace Avalonia_PC.Views var normalizedPath = string.Join('/', pathSegments); var query = ParseQueryParameters(uri.Query); - return new RouteRequestContext - { - NormalizedPath = normalizedPath, - PathSegments = pathSegments, - Query = query, - Body = body, - }; + return (normalizedPath, query); + } + + /// + /// 统一构建成功响应体,保持前后端响应结构一致。 + /// + private static string BuildSuccessResponseBody(object? data) + { + return JsonSerializer.Serialize(new { success = true, data }); } /// @@ -367,33 +359,6 @@ namespace Avalonia_PC.Views return query; } - /// - /// 按 body -> query -> path 的优先级提取业务输入参数。 - /// - private static string ExtractInput(RouteRequestContext requestContext) - { - if (!string.IsNullOrWhiteSpace(requestContext.Body)) - { - using var jsonDocument = JsonDocument.Parse(requestContext.Body); - if (jsonDocument.RootElement.TryGetProperty("input", out var inputProperty)) - { - return inputProperty.GetString() ?? string.Empty; - } - } - - if (requestContext.Query.TryGetValue("input", out var inputFromQuery) && - !string.IsNullOrWhiteSpace(inputFromQuery)) - { - return inputFromQuery; - } - - if (requestContext.PathSegments.Length > 2) - { - return string.Join('/', requestContext.PathSegments.Skip(2)); - } - - return string.Empty; - } /// /// 创建桥接响应的默认 JSON/CORS 头。 @@ -660,45 +625,6 @@ namespace Avalonia_PC.Views public Dictionary Headers { get; set; } = new(); } - private sealed class RouteRequestContext - { - public string NormalizedPath { get; init; } = string.Empty; - - public string[] PathSegments { get; init; } = []; - - public Dictionary Query { get; init; } = new(StringComparer.OrdinalIgnoreCase); - - public string? Body { get; init; } - } - - private sealed class RouteDispatchResult - { - public bool IsMatched { get; init; } - - public int StatusCode { get; init; } = 200; - - public string StatusMessage { get; init; } = "OK"; - - public object? Data { get; init; } - - public static RouteDispatchResult Success(object? data) - { - return new RouteDispatchResult - { - IsMatched = true, - Data = data, - }; - } - - public static RouteDispatchResult NotMatched() - { - return new RouteDispatchResult - { - IsMatched = false, - }; - } - } - #endregion } } diff --git a/Avalonia-Services/Avalonia-Services.csproj b/Avalonia-Services/Avalonia-Services.csproj index 5e3d7d1..e087112 100644 --- a/Avalonia-Services/Avalonia-Services.csproj +++ b/Avalonia-Services/Avalonia-Services.csproj @@ -7,4 +7,18 @@ enable + + + + + + + + + + + + + + diff --git a/Avalonia-Services/Core/EndpointPrinter.cs b/Avalonia-Services/Core/EndpointPrinter.cs new file mode 100644 index 0000000..ccb4e0e --- /dev/null +++ b/Avalonia-Services/Core/EndpointPrinter.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; + +namespace Avalonia_Services.Core +{ + /// + /// 端点列表打印工具 —— 在应用启动时输出所有已注册的拦截接口。 + /// 类似 Swagger 的接口清单效果。 + /// + public static class EndpointPrinter + { + /// + /// 打印所有已注册端点到控制台。 + /// + public static void PrintEndpoints(ServiceEndpointCollection collection, string? title = null) + { + title ??= "API Endpoints"; + + var maxMethodLen = collection.Endpoints.Count > 0 + ? collection.Endpoints.Max(e => e.HttpMethod.Length) + : 4; + var maxPathLen = collection.Endpoints.Count > 0 + ? collection.Endpoints.Max(e => e.Pattern.Length) + : 8; + + var totalWidth = maxMethodLen + maxPathLen + 5; + var separator = new string('─', Math.Max(totalWidth, 50)); + + Console.WriteLine(); + Console.WriteLine($"╔═ {title} ═{new string('═', Math.Max(0, totalWidth - title.Length - 3))}╗"); + Console.WriteLine($"║ {"Method".PadRight(maxMethodLen)} │ {"Path".PadRight(maxPathLen)} │ Auth ║"); + Console.WriteLine($"╟{separator}╢"); + + foreach (var ep in collection.Endpoints.OrderBy(e => e.Pattern)) + { + var auth = ep.RequireAuthorization ? (ep.Policy ?? "✓") : "—"; + var methodColor = ep.HttpMethod switch + { + "GET" => ConsoleColor.Green, + "POST" => ConsoleColor.Blue, + "PUT" => ConsoleColor.Yellow, + "DELETE" => ConsoleColor.Red, + _ => ConsoleColor.Gray, + }; + + var savedColor = Console.ForegroundColor; + + Console.Write("║ "); + Console.ForegroundColor = methodColor; + Console.Write(ep.HttpMethod.PadRight(maxMethodLen)); + Console.ForegroundColor = savedColor; + Console.Write(" │ "); + Console.Write(ep.Pattern.PadRight(maxPathLen)); + Console.Write(" │ "); + Console.Write(auth.PadRight(4)); + Console.WriteLine(" ║"); + } + + Console.WriteLine($"╚{separator}╝"); + Console.WriteLine($" Total: {collection.Endpoints.Count} endpoint(s)"); + Console.WriteLine(); + } + } +} diff --git a/Avalonia-Services/Core/GlobalExceptionFilter.cs b/Avalonia-Services/Core/GlobalExceptionFilter.cs new file mode 100644 index 0000000..25d4ba7 --- /dev/null +++ b/Avalonia-Services/Core/GlobalExceptionFilter.cs @@ -0,0 +1,93 @@ +using Avalonia_Common.Core; +using System; +using System.Threading.Tasks; + +namespace Avalonia_Services.Core +{ + /// + /// 全局异常拦截过滤器 —— 自动包裹所有端点处理器,无需在每个方法中写 try-catch。 + /// 所有未捕获异常会被转为统一的 ApiResponse 错误格式。 + /// + public sealed class GlobalExceptionFilter : IEndpointFilter + { + private readonly bool _includeDetails; + + /// + /// + /// 是否在响应中包含异常详情(开发环境建议 true,生产环境 false) + public GlobalExceptionFilter(bool includeDetails = false) + { + _includeDetails = includeDetails; + } + + public async Task InvokeAsync(ServiceEndpointContext context, EndpointFilterDelegate next) + { + try + { + await next(context); + } + catch (OperationCanceledException) + { + // 取消操作不视为错误 + context.StatusCode = 499; + context.StatusMessage = "Client Closed Request"; + context.ResponseBody = ApiResponse.Fail(499, "请求已取消"); + } + catch (UnauthorizedAccessException ex) + { + context.StatusCode = 401; + context.StatusMessage = "Unauthorized"; + context.ResponseBody = ApiResponse.Unauthorized( + _includeDetails ? ex.Message : "未授权访问"); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase)) + { + context.StatusCode = 404; + context.StatusMessage = "Not Found"; + context.ResponseBody = ApiResponse.NotFound( + _includeDetails ? ex.Message : "资源不存在"); + } + catch (ArgumentException ex) + { + context.StatusCode = 400; + context.StatusMessage = "Bad Request"; + context.ResponseBody = ApiResponse.BadRequest( + _includeDetails ? ex.Message : "参数错误"); + } + catch (Exception ex) + { + // 记录完整日志(无论是否返回详情) + LogException(context, ex); + + context.StatusCode = 500; + context.StatusMessage = "Internal Server Error"; + context.ResponseBody = ApiResponse.ServerError( + _includeDetails ? ex.Message : "服务器内部错误,请联系管理员"); + + // 可选:在开发环境附加堆栈信息 + if (_includeDetails) + { + // 通过 Items 传递额外调试信息 + context.Items["ExceptionDetail"] = ex.ToString(); + } + } + } + + private static void LogException(ServiceEndpointContext context, Exception ex) + { + try + { + // 使用 Serilog(如果已配置) + Serilog.Log.Error(ex, + "全局异常拦截 | {Method} {Path} | {ExceptionType}: {Message}", + context.Method, context.Path, ex.GetType().Name, ex.Message); + } + catch + { + // Serilog 不可用时回退到 Console + Console.Error.WriteLine( + $"[ERROR] {context.Method} {context.Path} | {ex.GetType().Name}: {ex.Message}"); + } + } + } +} diff --git a/Avalonia-Services/Core/IAuthService.cs b/Avalonia-Services/Core/IAuthService.cs new file mode 100644 index 0000000..475ce85 --- /dev/null +++ b/Avalonia-Services/Core/IAuthService.cs @@ -0,0 +1,38 @@ +using System.Security.Claims; + +namespace Avalonia_Services.Core +{ + /// + /// 鉴权服务抽象 —— 各宿主按自己的方式实现(JWT / Cookie / Token 等)。 + /// + public interface IAuthService + { + /// + /// 验证请求并返回用户主体;返回 null 表示未授权。 + /// + Task AuthenticateAsync(ServiceEndpointContext context); + + /// + /// 检查当前用户是否有指定权限。 + /// + Task AuthorizeAsync(ClaimsPrincipal user, string policy); + } + + /// + /// 无需鉴权的默认实现(开发/公开 API 场景)。 + /// + public sealed class AnonymousAuthService : IAuthService + { + public Task AuthenticateAsync(ServiceEndpointContext context) + { + // 匿名用户,始终通过 + var identity = new ClaimsIdentity("anonymous"); + return Task.FromResult(new ClaimsPrincipal(identity)); + } + + public Task AuthorizeAsync(ClaimsPrincipal user, string policy) + { + return Task.FromResult(true); + } + } +} diff --git a/Avalonia-Services/Core/IEndpointFilter.cs b/Avalonia-Services/Core/IEndpointFilter.cs new file mode 100644 index 0000000..776f81b --- /dev/null +++ b/Avalonia-Services/Core/IEndpointFilter.cs @@ -0,0 +1,40 @@ +using System.Threading.Tasks; + +namespace Avalonia_Services.Core +{ + /// + /// 端点过滤器抽象 —— 在请求处理前后执行逻辑。 + /// 类似于 ASP.NET Core 的 IEndpointFilter,但可在任何宿主中使用。 + /// + public interface IEndpointFilter + { + /// + /// 过滤器执行方法。 + /// 调用 next(ctx) 继续管道;不调用则短路。 + /// + Task InvokeAsync(ServiceEndpointContext context, EndpointFilterDelegate next); + } + + /// + /// 过滤器管道中的下一个委托。 + /// + public delegate Task EndpointFilterDelegate(ServiceEndpointContext context); + + /// + /// 用于包装匿名过滤器的简单实现。 + /// + internal sealed class AnonymousEndpointFilter : IEndpointFilter + { + private readonly Func _filter; + + public AnonymousEndpointFilter(Func filter) + { + _filter = filter; + } + + public Task InvokeAsync(ServiceEndpointContext context, EndpointFilterDelegate next) + { + return _filter(context, next); + } + } +} diff --git a/Avalonia-Services/Core/ServiceEndpointCollection.cs b/Avalonia-Services/Core/ServiceEndpointCollection.cs new file mode 100644 index 0000000..1aa4fdb --- /dev/null +++ b/Avalonia-Services/Core/ServiceEndpointCollection.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Avalonia_Services.Core +{ + /// + /// 单个端点定义。 + /// + public class ServiceEndpoint + { + /// 路由路径,如 "api/wData" + public string Pattern { get; init; } = string.Empty; + + /// HTTP 方法(GET/POST/PUT/DELETE) + public string HttpMethod { get; init; } = "GET"; + + /// 端点名称(用于 OpenAPI / 日志) + public string? Name { get; set; } + + /// 端点处理器 + public Func> Handler { get; init; } = _ => Task.FromResult(null); + + /// 该端点专属的过滤器(按顺序执行) + public List Filters { get; init; } = new(); + + /// 是否需要鉴权 + public bool RequireAuthorization { get; set; } + + /// 鉴权策略名 + public string? Policy { get; set; } + + /// + /// 设置端点名称(Fluent API)。 + /// + public ServiceEndpoint WithName(string name) + { + Name = name; + return this; + } + } + + /// + /// 端点集合 —— 所有端点的注册中心。在 Avalonia-Services 中统一配置。 + /// + public class ServiceEndpointCollection + { + /// 所有已注册的端点 + public List Endpoints { get; } = new(); + + /// 作用于所有端点的全局过滤器 + public List GlobalFilters { get; } = new(); + + /// + /// 注册一个端点。 + /// + public ServiceEndpoint MapGet(string pattern, Func> handler) + { + return AddEndpoint(pattern, "GET", handler); + } + + /// + /// 注册一个 POST 端点。 + /// + public ServiceEndpoint MapPost(string pattern, Func> handler) + { + return AddEndpoint(pattern, "POST", handler); + } + + /// + /// 注册一个 PUT 端点。 + /// + public ServiceEndpoint MapPut(string pattern, Func> handler) + { + return AddEndpoint(pattern, "PUT", handler); + } + + /// + /// 注册一个 DELETE 端点。 + /// + public ServiceEndpoint MapDelete(string pattern, Func> handler) + { + return AddEndpoint(pattern, "DELETE", handler); + } + + /// + /// 添加全局过滤器(作用于所有端点)。 + /// + public ServiceEndpointCollection AddGlobalFilter(IEndpointFilter filter) + { + GlobalFilters.Add(filter); + return this; + } + + /// + /// 通过匿名函数添加全局过滤器。 + /// + public ServiceEndpointCollection AddGlobalFilter(Func filter) + { + GlobalFilters.Add(new AnonymousEndpointFilter(filter)); + return this; + } + + private ServiceEndpoint AddEndpoint(string pattern, string method, Func> handler) + { + var endpoint = new ServiceEndpoint + { + Pattern = pattern, + HttpMethod = method, + Handler = handler, + }; + Endpoints.Add(endpoint); + return endpoint; + } + } + + /// + /// 构建器 —— 提供 Fluent API 来配置所有端点。 + /// + public class ServiceEndpointBuilder + { + /// + /// 端点集合 + /// + public ServiceEndpointCollection Endpoints { get; } = new(); + + /// + /// 鉴权服务(默认匿名) + /// + public IAuthService AuthService { get; set; } = new AnonymousAuthService(); + + /// + /// 配置端点(在此方法中调用 endpoints.MapGet 等)。 + /// + public ServiceEndpointBuilder ConfigureEndpoints(Action configure) + { + configure(Endpoints); + return this; + } + + /// + /// 设置鉴权服务。 + /// + public ServiceEndpointBuilder UseAuthService(IAuthService authService) + { + AuthService = authService; + return this; + } + + /// + /// 构建最终的端点集合。 + /// + public ServiceEndpointCollection Build() + { + return Endpoints; + } + } +} diff --git a/Avalonia-Services/Core/ServiceEndpointContext.cs b/Avalonia-Services/Core/ServiceEndpointContext.cs new file mode 100644 index 0000000..94be302 --- /dev/null +++ b/Avalonia-Services/Core/ServiceEndpointContext.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; + +namespace Avalonia_Services.Core +{ + /// + /// 抽象的请求上下文,屏蔽不同宿主(ASP.NET Core / Desktop WebView)的差异。 + /// + public class ServiceEndpointContext + { + /// + /// 请求路径,例如 "api/wData" + /// + public string Path { get; init; } = string.Empty; + + /// + /// HTTP 方法(GET, POST, PUT, DELETE 等) + /// + public string Method { get; init; } = "GET"; + + /// + /// 请求头 + /// + public Dictionary Headers { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// 请求体(原始字符串) + /// + public string? Body { get; set; } + + /// + /// 查询参数 + /// + public Dictionary Query { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// 响应状态码 + /// + public int StatusCode { get; set; } = 200; + + /// + /// 响应状态描述 + /// + public string StatusMessage { get; set; } = "OK"; + + /// + /// 响应头 + /// + public Dictionary ResponseHeaders { get; set; } = new(StringComparer.OrdinalIgnoreCase) + { + ["Content-Type"] = "application/json; charset=utf-8" + }; + + /// + /// 响应体 + /// + public object? ResponseBody { get; set; } + + /// + /// 存储在请求生命周期中的任意数据(由中间件/过滤器使用) + /// + public Dictionary Items { get; init; } = new(); + + /// + /// 获取请求头值 + /// + public string? GetHeader(string key) + { + return Headers.TryGetValue(key, out var value) ? value : null; + } + + /// + /// 设置响应头 + /// + public void SetResponseHeader(string key, string value) + { + ResponseHeaders[key] = value; + } + } +} diff --git a/Avalonia-Services/Database/AppDataContext.cs b/Avalonia-Services/Database/AppDataContext.cs new file mode 100644 index 0000000..150d91b --- /dev/null +++ b/Avalonia-Services/Database/AppDataContext.cs @@ -0,0 +1,37 @@ +using Avalonia_EFCore.Database; +using Avalonia_Services.Models; +using Microsoft.EntityFrameworkCore; + +namespace Avalonia_Services.Database +{ + /// + /// 应用数据库上下文 —— 继承自 Avalonia-EFCore 的 AppDbContext。 + /// 所有业务实体在此注册 DbSet。 + /// 这是 Avalonia-API 和 Avalonia-PC 共用的具体数据上下文。 + /// + public class AppDataContext(DatabaseConfiguration dbConfig) : AppDbContext(dbConfig) + { + /// 天气预报数据 + public DbSet WeatherForecasts => Set(); + + /// 用户数据 + public DbSet Users => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Summary).HasMaxLength(200); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Email).HasMaxLength(200); + }); + } + } +} diff --git a/Avalonia-Services/Endpoints/AppEndpoints.cs b/Avalonia-Services/Endpoints/AppEndpoints.cs new file mode 100644 index 0000000..8115c8b --- /dev/null +++ b/Avalonia-Services/Endpoints/AppEndpoints.cs @@ -0,0 +1,140 @@ +using Avalonia_Common.Core; +using Avalonia_Services.Core; +using Avalonia_Services.Database; +using Avalonia_Services.Models; +using Avalonia_Services.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Avalonia_Services.Endpoints +{ + /// + /// 统一端点配置 —— 所有业务端点在此定义一次。 + /// 这是 Avalonia-API 和 Avalonia-PC 的唯一入口。 + /// + public static class AppEndpoints + { + /// + /// 配置所有业务端点。调用方传入 builder,按需叠加鉴权、过滤器等。 + /// + /// 端点构建器 + /// 是否在错误响应中包含异常详情(开发环境 true) + public static ServiceEndpointBuilder Configure(ServiceEndpointBuilder builder, bool includeDetails = false) + { + // ---- 全局异常拦截(自动捕获所有端点中未处理的异常) ---- + builder.Endpoints.AddGlobalFilter(new GlobalExceptionFilter(includeDetails)); + + builder.ConfigureEndpoints(endpoints => + { + // ---- 全局日志过滤器(记录每个请求) ---- + endpoints.AddGlobalFilter(async (ctx, next) => + { + Serilog.Log.Debug("→ {Method} {Path}", ctx.Method, ctx.Path); + await next(ctx); + Serilog.Log.Debug("← {Method} {Path} | {StatusCode}", ctx.Method, ctx.Path, ctx.StatusCode); + }); + + // ---- 业务端点注册 ---- + // 天气预报(从数据库读取) + endpoints.MapGet("api/wData", GetWeatherForecastsAsync) + .WithName("GetWeatherForecast"); + + // 获取用户(演示从数据库查询) + endpoints.MapGet("api/getUser", GetUserFromDatabaseAsync) + .WithName("GetUser"); + + // 处理数据(POST — 演示参数处理) + endpoints.MapPost("api/processData", ProcessDataAsync) + .WithName("ProcessData"); + + // ---- 需要鉴权的端点示例 ---- + // endpoints.MapGet("api/admin/dashboard", AdminDashboardAsync) + // .WithName("AdminDashboard") + // .RequireAuthorization = true + // .Policy = "AdminOnly"; + + }); + + return builder; + } + + #region 业务处理方法 + + /// + /// 从数据库查询天气预报(优先数据库,回退到内存生成)。 + /// + private static async Task GetWeatherForecastsAsync(ServiceEndpointContext ctx) + { + var sp = ctx.Items["ServiceProvider"] as IServiceProvider; + + // 尝试从数据库读取 + if (sp?.GetService(typeof(AppDataContext)) is AppDataContext db) + { + var dbForecasts = await db.WeatherForecasts + .OrderByDescending(f => f.Date) + .Take(5) + .ToListAsync(); + + 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, "获取天气预报成功(内存生成)"); + } + + private static async Task GetUserFromDatabaseAsync(ServiceEndpointContext ctx) + { + var sp = ctx.Items["ServiceProvider"] as IServiceProvider; + + // 尝试从数据库读取用户 + if (sp?.GetService(typeof(AppDataContext)) is AppDataContext db) + { + var users = await db.Set().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); + } + + private static async Task 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() }); + } + + #endregion + } +} diff --git a/Avalonia-Services/Extensions/DesktopEndpointAdapter.cs b/Avalonia-Services/Extensions/DesktopEndpointAdapter.cs new file mode 100644 index 0000000..20f07e4 --- /dev/null +++ b/Avalonia-Services/Extensions/DesktopEndpointAdapter.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Avalonia_Services.Core; + +namespace Avalonia_Services.Extensions +{ + /// + /// Desktop (Avalonia-PC) 端点适配器。 + /// 将统一端点转换为桌面端可用的路由处理器,支持过滤器和鉴权管道。 + /// + public class DesktopEndpointAdapter + { + private readonly ServiceEndpointCollection _endpoints; + private readonly IAuthService _authService; + private readonly IServiceProvider _serviceProvider; + + /// + /// 匹配后的路由结果(与原有 RouteDispatchResult 兼容)。 + /// + public class RouteResult + { + public bool IsMatched { get; init; } + public int StatusCode { get; init; } = 200; + public string StatusMessage { get; init; } = "OK"; + public object? Data { get; init; } + public Dictionary ResponseHeaders { get; init; } = new(); + + public static RouteResult Success(object? data, ServiceEndpointContext ctx) + { + return new RouteResult + { + IsMatched = true, + StatusCode = ctx.StatusCode, + StatusMessage = ctx.StatusMessage, + Data = data, + ResponseHeaders = new Dictionary(ctx.ResponseHeaders, StringComparer.OrdinalIgnoreCase), + }; + } + + public static RouteResult NotFound() => new() + { + IsMatched = false, + StatusCode = 404, + StatusMessage = "Not Found", + }; + } + + public DesktopEndpointAdapter( + ServiceEndpointCollection endpoints, + IAuthService authService, + IServiceProvider serviceProvider) + { + _endpoints = endpoints; + _authService = authService; + _serviceProvider = serviceProvider; + } + + /// + /// 处理来自前端(WebView2 Bridge)的请求。 + /// + /// 规范化路径,如 "api/wData" + /// HTTP 方法 + /// 请求体字符串 + /// 请求头字典 + /// 查询参数字典 + public async Task HandleRequestAsync( + string path, + string method, + string? body, + Dictionary? headers = null, + Dictionary? query = null) + { + // 查找匹配的端点(忽略大小写 + 方法匹配) + var endpoint = _endpoints.Endpoints.FirstOrDefault(e => + string.Equals(e.Pattern, path, StringComparison.OrdinalIgnoreCase) && + string.Equals(e.HttpMethod, method, StringComparison.OrdinalIgnoreCase)); + + if (endpoint is null) + { + return RouteResult.NotFound(); + } + + // 构建上下文 + var ctx = new ServiceEndpointContext + { + Path = path, + Method = method, + Body = body, + Headers = headers ?? new Dictionary(StringComparer.OrdinalIgnoreCase), + Query = query ?? new Dictionary(StringComparer.OrdinalIgnoreCase), + Items = { ["ServiceProvider"] = _serviceProvider }, + }; + + try + { + // 1. 鉴权检查 + if (endpoint.RequireAuthorization) + { + var user = await _authService.AuthenticateAsync(ctx); + if (user is null) + { + ctx.StatusCode = 401; + ctx.StatusMessage = "Unauthorized"; + ctx.ResponseBody = new { success = false, error = "Unauthorized" }; + return RouteResult.Success(ctx.ResponseBody, ctx); + } + + if (!string.IsNullOrEmpty(endpoint.Policy)) + { + var authorized = await _authService.AuthorizeAsync(user, endpoint.Policy); + if (!authorized) + { + ctx.StatusCode = 403; + ctx.StatusMessage = "Forbidden"; + ctx.ResponseBody = new { success = false, error = "Forbidden" }; + return RouteResult.Success(ctx.ResponseBody, ctx); + } + } + + ctx.Items["User"] = user; + } + + // 2. 构建过滤管道:全局过滤器 → 端点过滤器 → 处理器 + var pipeline = BuildPipeline(endpoint); + + // 3. 执行管道 + await pipeline(ctx); + + return RouteResult.Success(ctx.ResponseBody, ctx); + } + catch (Exception ex) + { + ctx.StatusCode = 500; + ctx.StatusMessage = "Internal Server Error"; + ctx.ResponseBody = new { success = false, error = ex.Message }; + return RouteResult.Success(ctx.ResponseBody, ctx); + } + } + + /// + /// 构建过滤管道(全局过滤器 + 端点过滤器 → 端点处理器)。 + /// + private EndpointFilterDelegate BuildPipeline(ServiceEndpoint endpoint) + { + // 最内层:端点处理器 + EndpointFilterDelegate handler = async (ctx) => + { + ctx.ResponseBody = await endpoint.Handler(ctx); + }; + + // 先包裹端点专属过滤器(后注册的先执行) + var filters = new List(); + filters.AddRange(_endpoints.GlobalFilters); + filters.AddRange(endpoint.Filters); + + for (int i = filters.Count - 1; i >= 0; i--) + { + var filter = filters[i]; + var next = handler; + handler = (ctx) => filter.InvokeAsync(ctx, next); + } + + return handler; + } + } + + /// + /// Desktop 端的辅助扩展。不依赖 IServiceCollection(由宿主项目自行完成 DI 注册)。 + /// + public static class DesktopServiceExtensions + { + /// + /// 快速构建 DesktopEndpointAdapter(用于非 DI 场景如 MainWindow)。 + /// + public static DesktopEndpointAdapter CreateAdapter( + this ServiceEndpointCollection endpoints, + IServiceProvider serviceProvider) + { + var auth = (serviceProvider.GetService(typeof(IAuthService)) as IAuthService) ?? new AnonymousAuthService(); + return new DesktopEndpointAdapter(endpoints, auth, serviceProvider); + } + } +} diff --git a/Avalonia-Services/Models/UserEntity.cs b/Avalonia-Services/Models/UserEntity.cs new file mode 100644 index 0000000..aa94ea6 --- /dev/null +++ b/Avalonia-Services/Models/UserEntity.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Avalonia_Services.Models +{ + /// + /// 用户实体 —— 演示数据库 CRUD 操作。 + /// + [Table("Users")] + public class UserEntity + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + + [MaxLength(100)] + public string? Name { get; set; } + + [MaxLength(200)] + public string? Email { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Avalonia-Services/Models/WeatherForecastEntity.cs b/Avalonia-Services/Models/WeatherForecastEntity.cs new file mode 100644 index 0000000..e2b1460 --- /dev/null +++ b/Avalonia-Services/Models/WeatherForecastEntity.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Avalonia_Services.Models +{ + /// + /// 天气预报数据实体 —— 对应数据库中的 WeatherForecasts 表。 + /// + [Table("WeatherForecasts")] + public class WeatherForecastEntity + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + [MaxLength(200)] + public string? Summary { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } +}