using System.Text; using System.Text.Json.Serialization; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.BackgroundJobs; using Jiaowu.Api.Infrastructure.Configuration; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Middleware; using Jiaowu.Api.Infrastructure.Observability; using Jiaowu.Api.Infrastructure.OfficialDocuments; using Jiaowu.Api.Infrastructure.Operations; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Timetables; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Distributed; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using System.Threading.RateLimiting; EnvironmentFile.Load(); var migrateOnly = args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase); var seedDemoData = args.Contains("--seed-demo-data", StringComparer.OrdinalIgnoreCase); var confirmProductionDemoData = args.Contains( "--confirm-production-demo-data", StringComparer.OrdinalIgnoreCase); if (migrateOnly && seedDemoData) { Console.Error.WriteLine( "--migrate-only 与 --seed-demo-data 不能同时使用。请先迁移,再单独插入演示数据。"); Environment.ExitCode = 2; return; } if (seedDemoData && !confirmProductionDemoData) { Console.Error.WriteLine( "插入生产演示数据必须同时传入 --confirm-production-demo-data。"); Environment.ExitCode = 2; return; } if (confirmProductionDemoData && !seedDemoData) { Console.Error.WriteLine( "--confirm-production-demo-data 只能与 --seed-demo-data 同时使用。"); Environment.ExitCode = 2; return; } var builder = WebApplication.CreateBuilder(args); if (seedDemoData && builder.Environment.IsDevelopment()) { Console.Error.WriteLine( "--seed-demo-data 仅用于独立的非 Development 演示环境数据库。"); Environment.ExitCode = 2; return; } var databaseOptions = builder.Configuration .GetSection(DatabaseOptions.SectionName) .Get() ?? new DatabaseOptions(); var cacheOptions = builder.Configuration .GetSection(AppCacheOptions.SectionName) .Get() ?? new AppCacheOptions(); var officialDocumentOptions = builder.Configuration .GetSection(OfficialDocumentOptions.SectionName) .Get() ?? new OfficialDocumentOptions(); var backgroundJobOptions = builder.Configuration .GetSection(BackgroundJobOptions.SectionName) .Get() ?? new BackgroundJobOptions(); var operationsOptions = builder.Configuration .GetSection(OperationsOptions.SectionName) .Get() ?? new OperationsOptions(); var observabilityOptions = builder.Configuration .GetSection(ObservabilityOptions.SectionName) .Get() ?? new ObservabilityOptions(); var performanceReportingOptions = builder.Configuration .GetSection(PerformanceReportingOptions.SectionName) .Get() ?? new PerformanceReportingOptions(); var rabbitMqOptions = builder.Configuration .GetSection(RabbitMqOptions.SectionName) .Get() ?? new RabbitMqOptions(); var ssoOptions = builder.Configuration .GetSection(SsoOptions.SectionName) .Get() ?? new SsoOptions(); if (ssoOptions.Enabled && (string.IsNullOrWhiteSpace(ssoOptions.ClientId) || !Uri.TryCreate(ssoOptions.Authority, UriKind.Absolute, out var ssoAuthority) || ssoAuthority.Scheme is not ("http" or "https") || (ssoOptions.RequireHttpsMetadata && ssoAuthority.Scheme != "https") || string.IsNullOrWhiteSpace(ssoOptions.UserNameClaim) || (!string.IsNullOrWhiteSpace(ssoOptions.FrontendBaseUrl) && (!Uri.TryCreate(ssoOptions.FrontendBaseUrl, UriKind.Absolute, out var frontendBaseUrl) || frontendBaseUrl.Scheme is not ("http" or "https"))))) { throw new InvalidOperationException( "启用 Sso 时必须配置有效的 Authority、ClientId、UserNameClaim 和 FrontendBaseUrl;生产元数据地址必须使用 HTTPS。"); } if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) || string.IsNullOrWhiteSpace(officialDocumentOptions.IssuingOffice) || string.IsNullOrWhiteSpace(officialDocumentOptions.DocumentNumberPrefix)) { throw new InvalidOperationException( "OfficialDocuments 的 InstitutionName、IssuingOffice 和 DocumentNumberPrefix 不能为空。"); } if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase) && !builder.Environment.IsDevelopment()) { throw new InvalidOperationException("SQLite 仅允许在 Development 环境使用。生产环境请配置 MySql。"); } var allowedHosts = builder.Configuration["AllowedHosts"]; if (!builder.Environment.IsDevelopment() && (string.IsNullOrWhiteSpace(allowedHosts) || allowedHosts == "*")) { throw new InvalidOperationException( "生产环境必须通过 AllowedHosts 配置明确的访问域名,不能使用通配符。"); } if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300) { throw new InvalidOperationException( "Database:CommandTimeoutSeconds 必须在 5 到 300 秒之间。"); } if (string.IsNullOrWhiteSpace(observabilityOptions.ServiceName) || observabilityOptions.ServiceName.Length > 100 || observabilityOptions.SlowQueryThresholdMilliseconds is < 1 or > 60000 || observabilityOptions.MaximumSqlTextLength is < 256 or > 20000) { throw new InvalidOperationException( "Observability 服务名、慢查询阈值或 SQL 文本长度超出允许范围。"); } if (performanceReportingOptions.CacheSeconds is < 5 or > 300 || performanceReportingOptions.TimeoutSeconds is < 1 or > 60 || performanceReportingOptions.BearerToken.Length > 8000 || !PerformanceReportingOptions.IsMetricOrLabelName( performanceReportingOptions.ServiceLabel) || !PerformanceReportingOptions.IsMetricOrLabelName( performanceReportingOptions.RequestDurationMetric) || !PerformanceReportingOptions.IsMetricOrLabelName( performanceReportingOptions.DatabaseDurationMetric) || !PerformanceReportingOptions.IsMetricOrLabelName( performanceReportingOptions.SlowDatabaseMetric) || !PerformanceReportingOptions.IsMetricOrLabelName( performanceReportingOptions.FailedDatabaseMetric) || (performanceReportingOptions.Enabled && !IsHttpUrl(performanceReportingOptions.PrometheusBaseUrl)) || (!string.IsNullOrWhiteSpace(performanceReportingOptions.GrafanaBaseUrl) && !IsHttpUrl(performanceReportingOptions.GrafanaBaseUrl))) { throw new InvalidOperationException( "PerformanceReporting 数据源、超时、缓存或指标名称配置无效。"); } var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; if (!string.IsNullOrWhiteSpace(otlpEndpoint) && (!Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var parsedOtlpEndpoint) || parsedOtlpEndpoint.Scheme is not ("http" or "https"))) { throw new InvalidOperationException( "OTEL_EXPORTER_OTLP_ENDPOINT 必须是有效的 HTTP 或 HTTPS 绝对地址。"); } if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 || cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 || cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 || cacheOptions.ReferenceLocalExpirationSeconds is < 1 or > 3600 || cacheOptions.TimetableLocalExpirationSeconds is < 1 or > 3600 || cacheOptions.AnalyticsLocalExpirationSeconds is < 1 or > 3600 || cacheOptions.MaximumPayloadKilobytes is < 64 or > 16384 || cacheOptions.ReferenceLocalExpirationSeconds > cacheOptions.ReferenceExpirationMinutes * 60 || cacheOptions.TimetableLocalExpirationSeconds > cacheOptions.TimetableExpirationMinutes * 60 || cacheOptions.AnalyticsLocalExpirationSeconds > cacheOptions.AnalyticsExpirationMinutes * 60 || string.IsNullOrWhiteSpace(cacheOptions.KeyPrefix) || cacheOptions.KeyPrefix.Length > 100) { throw new InvalidOperationException( "Cache 缓存时间或 MaximumPayloadKilobytes 超出允许范围。"); } if (!backgroundJobOptions.Transport.Equals( "InMemory", StringComparison.OrdinalIgnoreCase) && !backgroundJobOptions.UsesRabbitMq) { throw new InvalidOperationException( "BackgroundJobs:Transport 仅支持 InMemory 或 RabbitMq。"); } if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 || backgroundJobOptions.LeaseSeconds is < 30 or > 3600 || backgroundJobOptions.PrefetchCount is < 1 or > 100 || backgroundJobOptions.AutomaticScheduleConcurrency is < 1 or > 16 || backgroundJobOptions.SchedulePublishConcurrency is < 1 or > 16 || backgroundJobOptions.MakeupExamAutoConcurrency is < 1 or > 16 || backgroundJobOptions.ExamArrangementConcurrency is < 1 or > 16 || backgroundJobOptions.ExamSignInExportConcurrency is < 1 or > 16 || backgroundJobOptions.ExamPublishConcurrency is < 1 or > 16 || backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 || backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 || backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 || backgroundJobOptions.CleanupBatchSize is < 10 or > 5000 || string.IsNullOrWhiteSpace(backgroundJobOptions.Exchange) || string.IsNullOrWhiteSpace(backgroundJobOptions.QueuePrefix)) { throw new InvalidOperationException("BackgroundJobs 配置超出允许范围。"); } if (backgroundJobOptions.UsesRabbitMq && (string.IsNullOrWhiteSpace(rabbitMqOptions.HostName) || rabbitMqOptions.Port is < 1 or > 65535 || string.IsNullOrWhiteSpace(rabbitMqOptions.UserName) || string.IsNullOrWhiteSpace(rabbitMqOptions.Password) || string.IsNullOrWhiteSpace(rabbitMqOptions.VirtualHost))) { throw new InvalidOperationException("RabbitMq 连接配置不完整。"); } if (backgroundJobOptions.UsesRabbitMq && !builder.Environment.IsDevelopment() && (rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) || rabbitMqOptions.Password == "guest")) { throw new InvalidOperationException( "生产环境启用 RabbitMQ 时不能使用默认 guest 凭据。"); } if (string.IsNullOrWhiteSpace(operationsOptions.BackupDirectory) || operationsOptions.BackupWarningHours is < 1 or > 8760 || operationsOptions.ToolTimeoutMinutes is < 1 or > 240 || string.IsNullOrWhiteSpace(operationsOptions.MySqlDumpPath) || string.IsNullOrWhiteSpace(operationsOptions.MySqlClientPath) || operationsOptions.MySqlAdditionalArguments.Length > 20 || operationsOptions.MySqlAdditionalArguments.Any(argument => string.IsNullOrWhiteSpace(argument) || argument.Length > 300 || !argument.StartsWith("--", StringComparison.Ordinal))) { throw new InvalidOperationException("Operations 运维与备份配置超出允许范围。"); } builder.Services.AddSingleton(databaseOptions); builder.Services.AddSingleton(cacheOptions); builder.Services.AddSingleton(officialDocumentOptions); builder.Services.AddSingleton(backgroundJobOptions); builder.Services.AddSingleton(operationsOptions); builder.Services.AddSingleton(observabilityOptions); builder.Services.AddSingleton(performanceReportingOptions); builder.Services.AddSingleton(rabbitMqOptions); builder.Services.AddSingleton(); builder.Services.AddMemoryCache(); builder.Services.AddHttpClient((services, client) => { var reporting = services.GetRequiredService(); client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds); }); builder.Services.Configure( builder.Configuration.GetSection(OfficialDocumentOptions.SectionName)); builder.Services.AddDbContextPool((services, options) => { options.AddInterceptors( services.GetRequiredService()); if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase)) { var sqliteConnectionString = builder.Configuration.GetConnectionString("SQLite") ?? throw new InvalidOperationException("缺少 ConnectionStrings:SQLite。"); var sqliteBuilder = new SqliteConnectionStringBuilder(sqliteConnectionString); if (!Path.IsPathRooted(sqliteBuilder.DataSource)) { sqliteBuilder.DataSource = Path.GetFullPath( sqliteBuilder.DataSource, builder.Environment.ContentRootPath); } Directory.CreateDirectory( Path.GetDirectoryName(sqliteBuilder.DataSource) ?? builder.Environment.ContentRootPath); options.UseSqlite(sqliteBuilder.ConnectionString); return; } if (!databaseOptions.Provider.Equals("MySql", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( $"不支持数据库 Provider '{databaseOptions.Provider}',可选值为 SQLite、MySql。"); } var connectionString = builder.Configuration.GetConnectionString("MySql"); if (string.IsNullOrWhiteSpace(connectionString)) { throw new InvalidOperationException( "缺少 MySQL 连接串。请通过 ConnectionStrings__MySql 环境变量配置。"); } options.UseMySQL( MySqlConnectionStringPolicy.ForApplication(connectionString), mySqlOptions => { mySqlOptions.CommandTimeout(databaseOptions.CommandTimeoutSeconds); mySqlOptions.EnableRetryOnFailure( maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(10), errorNumbersToAdd: null); }); }); if (observabilityOptions.Enabled && !string.IsNullOrWhiteSpace(otlpEndpoint)) { builder.Services .AddOpenTelemetry() .ConfigureResource(resource => resource.AddService(observabilityOptions.ServiceName)) .WithMetrics(metrics => metrics .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddRuntimeInstrumentation() .AddMeter(DatabaseCommandTelemetryInterceptor.MeterName)) .WithTracing(tracing => tracing .AddAspNetCoreInstrumentation(options => options.Filter = context => !context.Request.Path.StartsWithSegments("/health/live")) .AddHttpClientInstrumentation() .AddSource(DatabaseCommandTelemetryInterceptor.ActivitySourceName)) .WithMetrics(metrics => metrics.AddOtlpExporter()) .WithTracing(tracing => tracing.AddOtlpExporter()); } var redisConnectionString = builder.Configuration.GetConnectionString("Redis"); if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString)) { builder.Services.AddStackExchangeRedisCache(options => options.Configuration = redisConnectionString); } else { builder.Services.AddDistributedMemoryCache(); } builder.Services.AddHybridCache(options => { options.MaximumKeyLength = 512; options.MaximumPayloadBytes = cacheOptions.MaximumPayloadKilobytes * 1024; }); builder.Services.AddSingleton(); builder.Services .AddIdentityCore(options => { options.Password.RequiredLength = 8; options.Password.RequireDigit = true; options.Password.RequireLowercase = true; options.Password.RequireUppercase = true; options.Password.RequireNonAlphanumeric = true; options.User.RequireUniqueEmail = false; options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); }) .AddRoles() .AddEntityFrameworkStores(); var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get() ?? throw new InvalidOperationException("缺少 Jwt 配置。"); if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32 || jwtOptions.Key.Contains("REPLACE", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( "Jwt:Key 必须配置为至少 32 字节的随机生产密钥,不能使用示例值。"); } builder.Services.Configure( builder.Configuration.GetSection(JwtOptions.SectionName)); builder.Services.Configure( builder.Configuration.GetSection(SsoOptions.SectionName)); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); if (backgroundJobOptions.UsesRabbitMq) { builder.Services.AddSingleton< IBackgroundJobTransport, RabbitMqBackgroundJobTransport>(); builder.Services.AddHostedService(); } else { builder.Services.AddSingleton(); builder.Services.AddSingleton(services => services.GetRequiredService()); builder.Services.AddHostedService(); } builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddScoped(); var authentication = builder.Services .AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = jwtOptions.Issuer, ValidAudience = jwtOptions.Audience, IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(jwtOptions.Key)), ClockSkew = TimeSpan.FromMinutes(1) }; }) .AddCookie(SsoAuthSchemes.ExternalCookie, options => { options.Cookie.Name = "__Host-jiaowu-sso"; options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.ExpireTimeSpan = TimeSpan.FromMinutes(5); }); if (ssoOptions.Enabled) { authentication.AddOpenIdConnect(SsoAuthSchemes.Keycloak, options => { options.Authority = ssoOptions.Authority.TrimEnd('/'); options.ClientId = ssoOptions.ClientId; options.ClientSecret = ssoOptions.ClientSecret; options.SignInScheme = SsoAuthSchemes.ExternalCookie; options.ResponseType = "code"; options.UsePkce = true; options.SaveTokens = false; options.RequireHttpsMetadata = ssoOptions.RequireHttpsMetadata; options.CallbackPath = "/signin-keycloak"; options.GetClaimsFromUserInfoEndpoint = true; options.MapInboundClaims = false; options.ClaimActions.MapUniqueJsonKey( ssoOptions.UserNameClaim, ssoOptions.UserNameClaim); options.TokenValidationParameters.NameClaimType = ssoOptions.UserNameClaim; options.Events.OnRemoteFailure = context => { context.HandleResponse(); var loginUrl = string.IsNullOrWhiteSpace(ssoOptions.FrontendBaseUrl) ? "/login" : ssoOptions.FrontendBaseUrl.TrimEnd('/') + "/login"; context.Response.Redirect(loginUrl + "?ssoError=authentication_failed"); return Task.CompletedTask; }; }); } builder.Services.AddAuthorization(); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy("public-auth", context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(5), QueueLimit = 0, AutoReplenishment = true })); options.AddPolicy("official-verification", context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 30, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true })); options.AddPolicy("app-updates", context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 120, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true })); }); builder.Services.AddCors(options => { options.AddPolicy("Web", policy => { var origins = builder.Configuration.GetSection("Cors:Origins").Get() ?? []; if (origins.Length > 0) { policy.WithOrigins(origins) .AllowAnyHeader() .AllowAnyMethod(); } }); }); builder.Services.AddResponseCompression(options => options.EnableForHttps = true); builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler(options => { options.ExceptionHandler = async context => { var exception = context.Features .Get()?.Error; var isConstraintConflict = exception is DbUpdateException; var statusCode = isConstraintConflict ? StatusCodes.Status409Conflict : StatusCodes.Status500InternalServerError; context.Response.StatusCode = statusCode; await Results.Problem( title: isConstraintConflict ? "数据约束冲突" : "服务器处理请求时发生错误", detail: isConstraintConflict ? "编码可能已存在,或该数据正在被其他业务引用。" : builder.Environment.IsDevelopment() ? exception?.Message : "请稍后重试,并联系系统管理员查看日志。", statusCode: statusCode) .ExecuteAsync(context); }; }); builder.Services.AddControllers() .AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => { options.SwaggerDoc("v1", new OpenApiInfo { Title = "大学教务管理系统 API", Version = "v1" }); options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { Name = "Authorization", Type = SecuritySchemeType.Http, Scheme = "bearer", BearerFormat = "JWT", In = ParameterLocation.Header }); options.AddSecurityRequirement(new OpenApiSecurityRequirement { [ new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } } ] = [] }); }); var app = builder.Build(); app.UseExceptionHandler(); app.UseResponseCompression(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseDefaultFiles(); app.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = context => { if (context.Context.Request.Path.StartsWithSegments("/assets")) { context.Context.Response.Headers.CacheControl = "public,max-age=31536000,immutable"; } else if (string.Equals( Path.GetExtension(context.File.Name), ".html", StringComparison.OrdinalIgnoreCase)) { context.Context.Response.Headers.CacheControl = "no-cache,no-store"; } } }); app.UseCors("Web"); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); app.UseMiddleware(); app.MapControllers(); app.MapGet("/health/live", () => Results.Ok(new { Status = "healthy" })) .AllowAnonymous(); app.MapGet("/health", CheckDatabaseHealthAsync).AllowAnonymous(); app.MapGet("/health/ready", CheckDatabaseHealthAsync).AllowAnonymous(); app.MapGet("/health/cache", CheckCacheHealthAsync).AllowAnonymous(); app.MapGet("/health/messaging", CheckMessagingHealthAsync).AllowAnonymous(); app.MapFallback(async context => { if (context.Request.Path.StartsWithSegments("/api") || context.Request.Path.StartsWithSegments("/health") || context.Request.Path.StartsWithSegments("/swagger")) { context.Response.StatusCode = StatusCodes.Status404NotFound; return; } var webRoot = app.Environment.WebRootPath ?? Path.Combine(app.Environment.ContentRootPath, "wwwroot"); var indexPath = Path.Combine(webRoot, "index.html"); if (!File.Exists(indexPath)) { context.Response.StatusCode = StatusCodes.Status404NotFound; return; } context.Response.Headers.CacheControl = "no-cache,no-store"; context.Response.ContentType = "text/html; charset=utf-8"; await context.Response.SendFileAsync(indexPath); }); using (var scope = app.Services.CreateScope()) { await scope.ServiceProvider.GetRequiredService() .InitializeAsync(migrateOnly); if (seedDemoData) { await scope.ServiceProvider.GetRequiredService() .SeedAsync(); } } if (migrateOnly || seedDemoData) { return; } app.Run(); static async Task CheckDatabaseHealthAsync( AppDbContext db, CancellationToken cancellationToken) { try { return await db.Database.CanConnectAsync(cancellationToken) ? Results.Ok(new { Status = "healthy" }) : Results.Json( new { Status = "unhealthy" }, statusCode: StatusCodes.Status503ServiceUnavailable); } catch { return Results.Json( new { Status = "unhealthy" }, statusCode: StatusCodes.Status503ServiceUnavailable); } } static async Task CheckCacheHealthAsync( IServiceProvider services, CancellationToken cancellationToken) { var distributedCache = services.GetService(); if (distributedCache is null) return Results.Ok(new { Status = "disabled", Backend = "memory" }); try { await distributedCache.GetAsync( "jiaowu:health:probe", cancellationToken); return Results.Ok(new { Status = "healthy", Backend = "redis" }); } catch { return Results.Json( new { Status = "unhealthy", Backend = "redis" }, statusCode: StatusCodes.Status503ServiceUnavailable); } } static async Task CheckMessagingHealthAsync( IBackgroundJobTransport transport, BackgroundJobMonitoringService monitoring, CancellationToken cancellationToken) { var healthy = await transport.CheckHealthAsync(cancellationToken); var backend = transport.IsDurable ? "rabbitmq" : "memory"; try { var backlog = await monitoring.GetSnapshotAsync(cancellationToken); return healthy ? Results.Ok(new { Status = "healthy", Backend = backend, Backlog = backlog }) : Results.Json( new { Status = "unhealthy", Backend = backend, Backlog = backlog }, statusCode: StatusCodes.Status503ServiceUnavailable); } catch { return Results.Json( new { Status = "unhealthy", Backend = backend, Backlog = (object?)null }, statusCode: StatusCodes.Status503ServiceUnavailable); } } static bool IsHttpUrl(string value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https"; public partial class Program;