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(