From f4783c5de37fbe566e478d1d3d663609fe966c7b Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 13:39:06 +0800 Subject: [PATCH] feat: add runtime logging cors and zlinq foundation --- Directory.Packages.props | 6 + README.md | 39 +++ Tiku.Api/Logging/SerilogRequestLogging.cs | 45 ++++ Tiku.Api/Options/CorsOptions.cs | 12 + Tiku.Api/Program.cs | 252 +++++++++++------- Tiku.Api/Security/TenantRoleAuthorization.cs | 3 +- Tiku.Api/Tiku.Api.csproj | 6 + Tiku.Api/appsettings.Development.json | 22 +- Tiku.Api/appsettings.json | 44 ++- .../Api/TenantPublicEndpointTests.cs | 15 ++ 10 files changed, 340 insertions(+), 104 deletions(-) create mode 100644 Tiku.Api/Logging/SerilogRequestLogging.cs create mode 100644 Tiku.Api/Options/CorsOptions.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index be58427..49ea63a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,11 +29,17 @@ + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/README.md b/README.md index 38f2742..642f70f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ - Entity Framework Core - PostgreSQL - Npgsql +- Serilog +- ZLinq - xUnit 项目分层: @@ -42,6 +44,43 @@ Tiku.UnitTests # 单元测试 Tiku.IntegrationTests # EF 模型/持久化约束测试 ``` +## 运行时地基 + +这次迁移不只是把 Node/Nest/Supabase 换成 C#,而是把旧系统没有认真处理的运行时基础补起来。 + +### 数据库连接 + +- API 通过单例 `NpgsqlDataSource` 管理 PostgreSQL 连接池。 +- EF Core 使用 `AddDbContextPool`,避免每个请求重复构造完整 DbContext 依赖图。 +- 连接池参数继续交给 PostgreSQL/Npgsql connection string 配置,例如 `Maximum Pool Size`、`Minimum Pool Size`、`Timeout`、`Command Timeout`。 +- 业务代码不直接 new 连接,不绕过统一的 EF / Npgsql 配置入口。 + +### 日志 + +- API 接入 Serilog 结构化日志。 +- 启动阶段使用 bootstrap logger,避免应用启动失败时完全没有日志。 +- 请求日志统一记录 HTTP method、path、status code、elapsed 等基础字段。 +- 请求日志会补充当前用户、租户、Session、租户角色、TraceId 等上下文,后续排查“某个机构某个用户某次请求”会比旧方案清楚很多。 +- EF SQL、Microsoft 框架日志默认降噪;开发环境可提高 EF command 日志等级。 + +### 跨域 + +- CORS 作为 API 安全边界配置,不在 Controller 里散写。 +- 生产默认不放行任何 Origin,避免开发便利配置意外带到线上。 +- 开发环境默认允许本地前端常用端口: + - `http://localhost:5173` + - `http://127.0.0.1:5173` + - `http://localhost:3000` + - `http://127.0.0.1:3000` +- 不默认允许 credentials;如果后续需要 cookie 模式或管理后台单独域名,需要显式配置。 +- 多租户正式域名确定后,可以把 CORS 白名单从静态配置升级为“租户域名 + 平台管理域名”的集中策略。 + +### 热路径集合处理 + +- API 项目引入 ZLinq,作为低分配集合处理工具。 +- 当前先在小范围权限判断里落模板,后续题库筛选、权限集合、菜单/内容树投影等热路径再逐步使用。 +- 不是为了炫技替换所有 LINQ;只在明确高频、低收益分配明显的路径使用。 + ## 数据库策略 当前数据库以 PostgreSQL 为核心能力,而不是把 PostgreSQL 当成普通 KV 存储: diff --git a/Tiku.Api/Logging/SerilogRequestLogging.cs b/Tiku.Api/Logging/SerilogRequestLogging.cs new file mode 100644 index 0000000..20eba47 --- /dev/null +++ b/Tiku.Api/Logging/SerilogRequestLogging.cs @@ -0,0 +1,45 @@ +using Serilog; +using Serilog.AspNetCore; +using Serilog.Events; +using Tiku.Application.Security; + +namespace Tiku.Api.Logging; + +public static class SerilogRequestLogging +{ + public static void ConfigureRequestLogging(RequestLoggingOptions options) + { + options.GetLevel = (httpContext, elapsed, exception) => + exception is not null || httpContext.Response.StatusCode >= StatusCodes.Status500InternalServerError + ? LogEventLevel.Error + : elapsed > 1000 || httpContext.Response.StatusCode >= StatusCodes.Status400BadRequest + ? LogEventLevel.Warning + : LogEventLevel.Information; + + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); + diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme); + diagnosticContext.Set("TraceId", httpContext.TraceIdentifier); + + var user = httpContext.User; + SetClaim(diagnosticContext, "UserId", user, TikuClaimTypes.UserId); + SetClaim(diagnosticContext, "TenantId", user, TikuClaimTypes.TenantId); + SetClaim(diagnosticContext, "SessionId", user, TikuClaimTypes.SessionId); + SetClaim(diagnosticContext, "TenantRole", user, TikuClaimTypes.TenantRole); + }; + } + + private static void SetClaim( + IDiagnosticContext diagnosticContext, + string propertyName, + System.Security.Claims.ClaimsPrincipal principal, + string claimType) + { + var value = principal.FindFirst(claimType)?.Value; + if (!string.IsNullOrWhiteSpace(value)) + { + diagnosticContext.Set(propertyName, value); + } + } +} diff --git a/Tiku.Api/Options/CorsOptions.cs b/Tiku.Api/Options/CorsOptions.cs new file mode 100644 index 0000000..c608c7f --- /dev/null +++ b/Tiku.Api/Options/CorsOptions.cs @@ -0,0 +1,12 @@ +namespace Tiku.Api.Options; + +public sealed class CorsOptions +{ + public const string SectionName = "Cors"; + public const string PolicyName = "TikuCors"; + + public string[] AllowedOrigins { get; set; } = []; + public string[] AllowedHeaders { get; set; } = ["Authorization", "Content-Type", "x-tenant-code"]; + public string[] AllowedMethods { get; set; } = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; + public bool AllowCredentials { get; set; } +} diff --git a/Tiku.Api/Program.cs b/Tiku.Api/Program.cs index 3f824d5..11db74d 100644 --- a/Tiku.Api/Program.cs +++ b/Tiku.Api/Program.cs @@ -2,125 +2,185 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Scalar.AspNetCore; +using Serilog; +using Serilog.Events; using System.Text; using System.Text.Json.Serialization; +using Tiku.Api.Logging; using Tiku.Api.Middleware; using Tiku.Api.OpenApi; +using Tiku.Api.Options; using Tiku.Api.Security; using Tiku.Application; using Tiku.Application.Security; using Tiku.Infrastructure; using Tiku.Infrastructure.Persistence; -var builder = WebApplication.CreateBuilder(args); +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateBootstrapLogger(); -builder.Services.AddControllers() - .AddJsonOptions(options => - { - options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); - }); -builder.Services.AddOpenApi(options => +try { - options.AddDocumentTransformer(); -}); -builder.Services.AddProblemDetails(); -builder.Services.AddApplication(); + Log.Information("Starting TIKU API"); -var connectionString = - builder.Configuration.GetConnectionString("Database") ?? - builder.Configuration["DATABASE_URL"] ?? - "Host=localhost;Database=tiku;Username=postgres"; + var builder = WebApplication.CreateBuilder(args); + builder.Services.AddSerilog((services, configuration) => configuration + .ReadFrom.Configuration(builder.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext(), + preserveStaticLogger: true); -builder.Services.AddInfrastructure(connectionString); - -builder.Services.Configure(builder.Configuration.GetSection("Security:Jwt")); -var jwtOptions = builder.Configuration - .GetSection("Security:Jwt") - .Get() ?? new JwtOptions(); - -builder.Services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => + builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + builder.Services.AddOpenApi(options => { - options.TokenValidationParameters = new TokenValidationParameters + options.AddDocumentTransformer(); + }); + builder.Services.AddProblemDetails(); + builder.Services.AddApplication(); + builder.Services.Configure(builder.Configuration.GetSection(CorsOptions.SectionName)); + var corsOptions = builder.Configuration + .GetSection(CorsOptions.SectionName) + .Get() ?? new CorsOptions(); + builder.Services.AddCors(options => + { + options.AddPolicy(CorsOptions.PolicyName, policy => { - ValidateIssuer = true, - ValidIssuer = jwtOptions.Issuer, - ValidateAudience = true, - ValidAudience = jwtOptions.Audience, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)), - ValidateLifetime = true, - ClockSkew = TimeSpan.FromMinutes(1) - }; - options.Events = new JwtBearerEvents - { - OnTokenValidated = async context => + var origins = corsOptions.AllowedOrigins + .Where(origin => !string.IsNullOrWhiteSpace(origin)) + .Select(origin => origin.Trim().TrimEnd('/')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (origins.Length > 0) { - if (!jwtOptions.ValidateSessions) - { - return; - } - - var sessionIdValue = context.Principal?.FindFirst(TikuClaimTypes.SessionId)?.Value; - if (!Guid.TryParse(sessionIdValue, out var sessionId)) - { - context.Fail("Missing session claim."); - return; - } - - var dbContext = context.HttpContext.RequestServices.GetRequiredService(); - var now = DateTimeOffset.UtcNow; - var isSessionActive = await dbContext.AuthSessions.AnyAsync( - session => - session.Id == sessionId && - session.RevokedAt == null && - session.ExpiresAt > now); - - if (!isSessionActive) - { - context.Fail("Session has been revoked or expired."); - } + policy.WithOrigins(origins); } - }; + + policy + .WithHeaders(corsOptions.AllowedHeaders) + .WithMethods(corsOptions.AllowedMethods); + + if (corsOptions.AllowCredentials) + { + policy.AllowCredentials(); + } + }); }); -builder.Services.AddAuthorization(options => -{ - options.AddPolicy( - TikuPolicies.AuthenticatedUser, - policy => policy.RequireAuthenticatedUser()); - options.AddPolicy( - TikuPolicies.CurrentTenantMember, - policy => policy - .RequireAuthenticatedUser() - .RequireAssertion(context => TenantRoleAuthorization.IsTenantMember(context.User))); - options.AddPolicy( - TikuPolicies.TenantAdmin, - policy => policy - .RequireAuthenticatedUser() - .RequireAssertion(context => TenantRoleAuthorization.IsTenantAdmin(context.User))); -}); + var connectionString = + builder.Configuration.GetConnectionString("Database") ?? + builder.Configuration["DATABASE_URL"] ?? + "Host=localhost;Database=tiku;Username=postgres"; -var app = builder.Build(); + builder.Services.AddInfrastructure(connectionString); -if (app.Environment.IsDevelopment()) + builder.Services.Configure(builder.Configuration.GetSection("Security:Jwt")); + var jwtOptions = builder.Configuration + .GetSection("Security:Jwt") + .Get() ?? new JwtOptions(); + + builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtOptions.Issuer, + ValidateAudience = true, + ValidAudience = jwtOptions.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1) + }; + options.Events = new JwtBearerEvents + { + OnTokenValidated = async context => + { + if (!jwtOptions.ValidateSessions) + { + return; + } + + var sessionIdValue = context.Principal?.FindFirst(TikuClaimTypes.SessionId)?.Value; + if (!Guid.TryParse(sessionIdValue, out var sessionId)) + { + context.Fail("Missing session claim."); + return; + } + + var dbContext = context.HttpContext.RequestServices.GetRequiredService(); + var now = DateTimeOffset.UtcNow; + var isSessionActive = await dbContext.AuthSessions.AnyAsync( + session => + session.Id == sessionId && + session.RevokedAt == null && + session.ExpiresAt > now); + + if (!isSessionActive) + { + context.Fail("Session has been revoked or expired."); + } + } + }; + }); + + builder.Services.AddAuthorization(options => + { + options.AddPolicy( + TikuPolicies.AuthenticatedUser, + policy => policy.RequireAuthenticatedUser()); + options.AddPolicy( + TikuPolicies.CurrentTenantMember, + policy => policy + .RequireAuthenticatedUser() + .RequireAssertion(context => TenantRoleAuthorization.IsTenantMember(context.User))); + options.AddPolicy( + TikuPolicies.TenantAdmin, + policy => policy + .RequireAuthenticatedUser() + .RequireAssertion(context => TenantRoleAuthorization.IsTenantAdmin(context.User))); + }); + + var app = builder.Build(); + + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + app.MapScalarApiReference(options => options + .WithTitle("TIKU Backend API") + .AddPreferredSecuritySchemes("BearerAuth") + .EnablePersistentAuthentication()); + } + + app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging); + app.UseMiddleware(); + app.UseHttpsRedirection(); + app.UseCors(CorsOptions.PolicyName); + app.UseAuthentication(); + app.UseMiddleware(); + app.UseAuthorization(); + + app.MapControllers(); + + app.Run(); +} +catch (Exception exception) { - app.MapOpenApi(); - app.MapScalarApiReference(options => options - .WithTitle("TIKU Backend API") - .AddPreferredSecuritySchemes("BearerAuth") - .EnablePersistentAuthentication()); + Log.Fatal(exception, "TIKU API terminated unexpectedly"); + throw; +} +finally +{ + Log.CloseAndFlush(); } -app.UseMiddleware(); -app.UseHttpsRedirection(); -app.UseAuthentication(); -app.UseMiddleware(); -app.UseAuthorization(); - -app.MapControllers(); - -app.Run(); - public partial class Program; diff --git a/Tiku.Api/Security/TenantRoleAuthorization.cs b/Tiku.Api/Security/TenantRoleAuthorization.cs index 6240a5d..aa01d5b 100644 --- a/Tiku.Api/Security/TenantRoleAuthorization.cs +++ b/Tiku.Api/Security/TenantRoleAuthorization.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Tiku.Application.Security; using Tiku.Domain.Tenancy; +using ZLinq; namespace Tiku.Api.Security; @@ -22,7 +23,7 @@ internal static class TenantRoleAuthorization public static bool IsTenantAdmin(ClaimsPrincipal principal) { return IsTenantMember(principal) && - principal.Claims + principal.Claims.AsValueEnumerable() .Where(claim => claim.Type == TikuClaimTypes.TenantRole) .Select(claim => claim.Value) .Any(role => AdminRoles.Contains(role)); diff --git a/Tiku.Api/Tiku.Api.csproj b/Tiku.Api/Tiku.Api.csproj index 5b9ee05..a8c189d 100644 --- a/Tiku.Api/Tiku.Api.csproj +++ b/Tiku.Api/Tiku.Api.csproj @@ -16,7 +16,13 @@ + + + + + + diff --git a/Tiku.Api/appsettings.Development.json b/Tiku.Api/appsettings.Development.json index 0c208ae..6991caa 100644 --- a/Tiku.Api/appsettings.Development.json +++ b/Tiku.Api/appsettings.Development.json @@ -1,8 +1,22 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "System.Net.Http.HttpClient": "Warning" + } } + }, + "Cors": { + "AllowedOrigins": [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:3000", + "http://127.0.0.1:3000" + ], + "AllowCredentials": false } } diff --git a/Tiku.Api/appsettings.json b/Tiku.Api/appsettings.json index cfb8076..8381815 100644 --- a/Tiku.Api/appsettings.json +++ b/Tiku.Api/appsettings.json @@ -1,10 +1,48 @@ { - "Logging": { - "LogLevel": { + "Serilog": { + "MinimumLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning", + "System.Net.Http.HttpClient": "Warning" + } + }, + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ], + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} {Message:lj} {Properties:j}{NewLine}{Exception}" + } + } + ], + "Properties": { + "Application": "Tiku.Api" } }, + "Cors": { + "AllowedOrigins": [], + "AllowedHeaders": [ + "Authorization", + "Content-Type", + "x-tenant-code" + ], + "AllowedMethods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS" + ], + "AllowCredentials": false + }, "Security": { "Jwt": { "Issuer": "tiku-backend", diff --git a/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs index 7e24a9c..7b0101b 100644 --- a/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs @@ -95,6 +95,21 @@ public sealed class TenantPublicEndpointTests Assert.Equal("ok", body.GetProperty("status").GetString()); } + [Fact] + public async Task Cors_preflight_allows_configured_development_origin() + { + await using var factory = new ApiTestFactory(); + using var client = factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Options, "/api/health"); + request.Headers.Add("Origin", "http://localhost:5173"); + request.Headers.Add("Access-Control-Request-Method", "GET"); + + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal("http://localhost:5173", response.Headers.GetValues("Access-Control-Allow-Origin").Single()); + } + private static Task SeedTenantAsync(ApiTestFactory factory, Guid tenantId) { return factory.SeedAsync(