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.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<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="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="ZLinq" Version="1.5.6" />
</ItemGroup>
</Project>

View File

@@ -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<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 存储:

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,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<BearerSecuritySchemeTransformer>();
});
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<JwtOptions>(builder.Configuration.GetSection("Security:Jwt"));
var jwtOptions = builder.Configuration
.GetSection("Security:Jwt")
.Get<JwtOptions>() ?? 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<BearerSecuritySchemeTransformer>();
});
builder.Services.AddProblemDetails();
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 =>
{
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<TikuDbContext>();
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<JwtOptions>(builder.Configuration.GetSection("Security:Jwt"));
var jwtOptions = builder.Configuration
.GetSection("Security:Jwt")
.Get<JwtOptions>() ?? 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<TikuDbContext>();
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<ExceptionHandlingMiddleware>();
app.UseHttpsRedirection();
app.UseCors(CorsOptions.PolicyName);
app.UseAuthentication();
app.UseMiddleware<CurrentPrincipalMiddleware>();
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<ExceptionHandlingMiddleware>();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseMiddleware<CurrentPrincipalMiddleware>();
app.UseAuthorization();
app.MapControllers();
app.Run();
public partial class Program;

View File

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

View File

@@ -16,7 +16,13 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.OpenApi" />
<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="ZLinq" />
</ItemGroup>
</Project>

View File

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

View File

@@ -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",

View File

@@ -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(