feat: add runtime logging cors and zlinq foundation

This commit is contained in:
xiong
2026-07-26 13:39:06 +08:00
parent f0553a7919
commit f4783c5de3
10 changed files with 340 additions and 104 deletions

View File

@@ -29,11 +29,17 @@
<PackageVersion Include="Npgsql" Version="10.0.3" /> <PackageVersion Include="Npgsql" Version="10.0.3" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" /> <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" /> <PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.19.2" /> <PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.19.2" />
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4"> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageVersion> </PackageVersion>
<PackageVersion Include="ZLinq" Version="1.5.6" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -27,6 +27,8 @@
- Entity Framework Core - Entity Framework Core
- PostgreSQL - PostgreSQL
- Npgsql - Npgsql
- Serilog
- ZLinq
- xUnit - xUnit
项目分层: 项目分层:
@@ -42,6 +44,43 @@ Tiku.UnitTests # 单元测试
Tiku.IntegrationTests # EF 模型/持久化约束测试 Tiku.IntegrationTests # EF 模型/持久化约束测试
``` ```
## 运行时地基
这次迁移不只是把 Node/Nest/Supabase 换成 C#,而是把旧系统没有认真处理的运行时基础补起来。
### 数据库连接
- API 通过单例 `NpgsqlDataSource` 管理 PostgreSQL 连接池。
- EF Core 使用 `AddDbContextPool<TikuDbContext>`,避免每个请求重复构造完整 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 存储: 当前数据库以 PostgreSQL 为核心能力,而不是把 PostgreSQL 当成普通 KV 存储:

View File

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

View File

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

View File

@@ -2,17 +2,36 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using Serilog;
using Serilog.Events;
using System.Text; using System.Text;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Tiku.Api.Logging;
using Tiku.Api.Middleware; using Tiku.Api.Middleware;
using Tiku.Api.OpenApi; using Tiku.Api.OpenApi;
using Tiku.Api.Options;
using Tiku.Api.Security; using Tiku.Api.Security;
using Tiku.Application; using Tiku.Application;
using Tiku.Application.Security; using Tiku.Application.Security;
using Tiku.Infrastructure; using Tiku.Infrastructure;
using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Persistence;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
Log.Information("Starting TIKU API");
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog((services, configuration) => configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext(),
preserveStaticLogger: true);
builder.Services.AddControllers() builder.Services.AddControllers()
.AddJsonOptions(options => .AddJsonOptions(options =>
@@ -25,6 +44,35 @@ builder.Services.AddOpenApi(options =>
}); });
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.Configure<CorsOptions>(builder.Configuration.GetSection(CorsOptions.SectionName));
var corsOptions = builder.Configuration
.GetSection(CorsOptions.SectionName)
.Get<CorsOptions>() ?? new CorsOptions();
builder.Services.AddCors(options =>
{
options.AddPolicy(CorsOptions.PolicyName, policy =>
{
var origins = corsOptions.AllowedOrigins
.Where(origin => !string.IsNullOrWhiteSpace(origin))
.Select(origin => origin.Trim().TrimEnd('/'))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (origins.Length > 0)
{
policy.WithOrigins(origins);
}
policy
.WithHeaders(corsOptions.AllowedHeaders)
.WithMethods(corsOptions.AllowedMethods);
if (corsOptions.AllowCredentials)
{
policy.AllowCredentials();
}
});
});
var connectionString = var connectionString =
builder.Configuration.GetConnectionString("Database") ?? builder.Configuration.GetConnectionString("Database") ??
@@ -113,8 +161,10 @@ if (app.Environment.IsDevelopment())
.EnablePersistentAuthentication()); .EnablePersistentAuthentication());
} }
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
app.UseMiddleware<ExceptionHandlingMiddleware>(); app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseCors(CorsOptions.PolicyName);
app.UseAuthentication(); app.UseAuthentication();
app.UseMiddleware<CurrentPrincipalMiddleware>(); app.UseMiddleware<CurrentPrincipalMiddleware>();
app.UseAuthorization(); app.UseAuthorization();
@@ -122,5 +172,15 @@ app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();
}
catch (Exception exception)
{
Log.Fatal(exception, "TIKU API terminated unexpectedly");
throw;
}
finally
{
Log.CloseAndFlush();
}
public partial class Program; public partial class Program;

View File

@@ -1,6 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using Tiku.Application.Security; using Tiku.Application.Security;
using Tiku.Domain.Tenancy; using Tiku.Domain.Tenancy;
using ZLinq;
namespace Tiku.Api.Security; namespace Tiku.Api.Security;
@@ -22,7 +23,7 @@ internal static class TenantRoleAuthorization
public static bool IsTenantAdmin(ClaimsPrincipal principal) public static bool IsTenantAdmin(ClaimsPrincipal principal)
{ {
return IsTenantMember(principal) && return IsTenantMember(principal) &&
principal.Claims principal.Claims.AsValueEnumerable()
.Where(claim => claim.Type == TikuClaimTypes.TenantRole) .Where(claim => claim.Type == TikuClaimTypes.TenantRole)
.Select(claim => claim.Value) .Select(claim => claim.Value)
.Any(role => AdminRoles.Contains(role)); .Any(role => AdminRoles.Contains(role));

View File

@@ -16,7 +16,13 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.OpenApi" /> <PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" /> <PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Enrichers.Environment" />
<PackageReference Include="Serilog.Enrichers.Thread" />
<PackageReference Include="Serilog.Settings.Configuration" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="ZLinq" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,8 +1,22 @@
{ {
"Logging": { "Serilog": {
"LogLevel": { "MinimumLevel": {
"Default": "Information", "Default": "Debug",
"Microsoft.AspNetCore": "Warning" "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
}
} }

View File

@@ -1,10 +1,48 @@
{ {
"Logging": { "Serilog": {
"LogLevel": { "MinimumLevel": {
"Default": "Information", "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": { "Security": {
"Jwt": { "Jwt": {
"Issuer": "tiku-backend", "Issuer": "tiku-backend",

View File

@@ -95,6 +95,21 @@ public sealed class TenantPublicEndpointTests
Assert.Equal("ok", body.GetProperty("status").GetString()); 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) private static Task SeedTenantAsync(ApiTestFactory factory, Guid tenantId)
{ {
return factory.SeedAsync( return factory.SeedAsync(