Files
Academic-Affairs-System/src/Jiaowu.Api/Program.cs
T

865 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text;
using System.Net;
using System.Text.Json.Serialization;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Configuration;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Analytics;
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.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerUI;
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);
var swaggerDocumentVersion = typeof(Program).Assembly
.GetCustomAttributes(
typeof(System.Reflection.AssemblyMetadataAttribute),
inherit: false)
.OfType<System.Reflection.AssemblyMetadataAttribute>()
.SingleOrDefault(x => x.Key == "SwaggerDocumentVersion")?.Value
?? "v1";
if (seedDemoData && builder.Environment.IsDevelopment())
{
Console.Error.WriteLine(
"--seed-demo-data 仅用于独立的非 Development 演示环境数据库。");
Environment.ExitCode = 2;
return;
}
var databaseOptions = builder.Configuration
.GetSection(DatabaseOptions.SectionName)
.Get<DatabaseOptions>() ?? new DatabaseOptions();
var cacheOptions = builder.Configuration
.GetSection(AppCacheOptions.SectionName)
.Get<AppCacheOptions>() ?? new AppCacheOptions();
var officialDocumentOptions = builder.Configuration
.GetSection(OfficialDocumentOptions.SectionName)
.Get<OfficialDocumentOptions>() ?? new OfficialDocumentOptions();
var backgroundJobOptions = builder.Configuration
.GetSection(BackgroundJobOptions.SectionName)
.Get<BackgroundJobOptions>() ?? new BackgroundJobOptions();
var operationsOptions = builder.Configuration
.GetSection(OperationsOptions.SectionName)
.Get<OperationsOptions>() ?? new OperationsOptions();
var observabilityOptions = builder.Configuration
.GetSection(ObservabilityOptions.SectionName)
.Get<ObservabilityOptions>() ?? new ObservabilityOptions();
var performanceReportingOptions = builder.Configuration
.GetSection(PerformanceReportingOptions.SectionName)
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
var clickHouseAnalyticsOptions = builder.Configuration
.GetSection(ClickHouseAnalyticsOptions.SectionName)
.Get<ClickHouseAnalyticsOptions>() ?? new ClickHouseAnalyticsOptions();
var rabbitMqOptions = builder.Configuration
.GetSection(RabbitMqOptions.SectionName)
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
var ssoOptions = builder.Configuration
.GetSection(SsoOptions.SectionName)
.Get<SsoOptions>() ?? new SsoOptions();
var trustedProxyAddresses = builder.Configuration
.GetSection("ReverseProxy:TrustedProxies")
.Get<string[]>() ?? [];
var trustedProxies = trustedProxyAddresses
.Select(value =>
{
if (!IPAddress.TryParse(value, out var address))
{
throw new InvalidOperationException(
$"ReverseProxy:TrustedProxies contains an invalid IP address: '{value}'.");
}
return address;
})
.ToArray();
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.CallbackUrl) &&
(!Uri.TryCreate(ssoOptions.CallbackUrl, UriKind.Absolute, out var callbackUrl) ||
callbackUrl.Scheme is not ("http" or "https") ||
!callbackUrl.AbsolutePath.EndsWith(
"/signin-keycloak",
StringComparison.OrdinalIgnoreCase))) ||
(!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、CallbackUrl 和 FrontendBaseUrlCallbackUrl 必须以 /signin-keycloak 结尾,生产元数据地址必须使用 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.SlowRequestThresholdMilliseconds is < 1 or > 60000 ||
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 数据源、超时、缓存或指标名称配置无效。");
}
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.CourseGradeStatisticsRefreshConcurrency 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 (clickHouseAnalyticsOptions.Enabled &&
(!Uri.TryCreate(clickHouseAnalyticsOptions.Endpoint, UriKind.Absolute, out var clickHouseEndpoint) ||
clickHouseEndpoint.Scheme is not ("http" or "https") ||
!clickHouseAnalyticsOptions.HasValidIdentifiers() ||
string.IsNullOrWhiteSpace(clickHouseAnalyticsOptions.UserName) ||
string.IsNullOrWhiteSpace(clickHouseAnalyticsOptions.Password) ||
clickHouseAnalyticsOptions.SyncIntervalSeconds is < 10 or > 86400 ||
clickHouseAnalyticsOptions.SourceLookbackDays is < 1 or > 3650 ||
clickHouseAnalyticsOptions.BatchSize is < 1 or > 10000))
{
throw new InvalidOperationException("ClickHouseAnalytics 配置无效。");
}
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(clickHouseAnalyticsOptions);
builder.Services.AddSingleton(rabbitMqOptions);
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<PerformanceReportService>((services, client) =>
{
var reporting = services.GetRequiredService<PerformanceReportingOptions>();
client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds);
});
builder.Services.AddHttpClient<ClickHouseAnalyticsClient>((_, client) =>
{
client.BaseAddress = new Uri(clickHouseAnalyticsOptions.Endpoint.TrimEnd('/') + "/");
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.Configure<OfficialDocumentOptions>(
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
{
options.AddInterceptors(
services.GetRequiredService<DatabaseCommandTelemetryInterceptor>());
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);
});
});
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<IAppCache, HybridAppCache>();
builder.Services
.AddIdentityCore<ApplicationUser>(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<ApplicationRole>()
.AddEntityFrameworkStores<AppDbContext>();
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? throw new InvalidOperationException("缺少 Jwt 配置。");
if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32 ||
jwtOptions.Key.Contains("REPLACE", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"Jwt:Key 必须配置为至少 32 字节的随机生产密钥,不能使用示例值。");
}
if (jwtOptions.AccessTokenMinutes is < 1 or > 30 ||
jwtOptions.WebIdleMinutes is < 5 or > 1440 ||
jwtOptions.AppIdleMinutes is < 60 or > 43200 ||
jwtOptions.AccessTokenMinutes > jwtOptions.WebIdleMinutes)
{
throw new InvalidOperationException(
"Jwt 访问令牌或 Web/App 空闲有效期配置超出允许范围。");
}
builder.Services.Configure<JwtOptions>(
builder.Configuration.GetSection(JwtOptions.SectionName));
builder.Services.Configure<SsoOptions>(
builder.Configuration.GetSection(SsoOptions.SectionName));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthSessionService, AuthSessionService>();
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<DemoDataSeeder>();
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services.AddScoped<TimetableDataService>();
builder.Services.AddScoped<PublishedTimetableProjectionService>();
builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services.AddScoped<PersonalCalendarService>();
builder.Services.AddScoped<ClassroomReservationAvailabilityService>();
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
builder.Services.AddScoped<SchedulePlanPublisher>();
builder.Services.AddScoped<SchedulePublishJobProcessor>();
builder.Services.AddHostedService<WarningCheckWorker>();
builder.Services.AddScoped<ExamArrangementService>();
builder.Services.AddScoped<MakeupExamEligibilityService>();
builder.Services.AddScoped<MakeupExamArrangementService>();
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
builder.Services.AddScoped<ExamArrangementJobProcessor>();
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
builder.Services.AddScoped<ExamPublishJobProcessor>();
builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
builder.Services.AddScoped<CourseGradeStatisticsRefreshScheduler>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddHostedService<CourseGradeStatisticsRefreshWorker>();
builder.Services.AddHostedService<ClickHouseAnalyticsProjectionWorker>();
builder.Services.AddSingleton<BackgroundJobTelemetry>();
builder.Services.AddScoped<BackgroundJobMonitoringService>();
builder.Services.AddScoped<OperationalHealthService>();
builder.Services.AddSingleton<DatabaseBackupService>();
builder.Services.AddSingleton<BackgroundJobRunner>();
if (backgroundJobOptions.UsesRabbitMq)
{
builder.Services.AddSingleton<
IBackgroundJobTransport,
RabbitMqBackgroundJobTransport>();
builder.Services.AddHostedService<RabbitMqBackgroundJobWorker>();
}
else
{
builder.Services.AddSingleton<InMemoryBackgroundJobTransport>();
builder.Services.AddSingleton<IBackgroundJobTransport>(services =>
services.GetRequiredService<InMemoryBackgroundJobTransport>());
builder.Services.AddHostedService<InMemoryBackgroundJobWorker>();
}
builder.Services.AddHostedService<BackgroundJobOutboxPublisher>();
builder.Services.AddSingleton<IOfficialDocumentPdfGenerator, OfficialDocumentPdfGenerator>();
builder.Services.AddScoped<OfficialDocumentService>();
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.OnRedirectToIdentityProvider = context =>
{
if (!string.IsNullOrWhiteSpace(ssoOptions.CallbackUrl))
context.ProtocolMessage.RedirectUri = ssoOptions.CallbackUrl;
return Task.CompletedTask;
};
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.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.ForwardLimit = 1;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
foreach (var proxy in trustedProxies)
{
options.KnownProxies.Add(proxy);
}
});
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("token-refresh", context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 600,
Window = TimeSpan.FromMinutes(1),
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<string[]>()
?? [];
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<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>()?.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(swaggerDocumentVersion, new OpenApiInfo
{
Title = "大学教务管理系统 API",
Version = swaggerDocumentVersion
});
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header
});
options.AddSecurityRequirement(_ => new OpenApiSecurityRequirement
{
[
new OpenApiSecuritySchemeReference("Bearer", null, null)
] = []
});
});
var app = builder.Build();
app.UseForwardedHeaders();
app.UseExceptionHandler();
app.UseResponseCompression();
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/swagger"))
{
var isEnabled = await context.RequestServices
.GetRequiredService<AppDbContext>()
.SystemFeatureSettings
.AsNoTracking()
.Where(x => x.Key == SystemFeatureKeys.SwaggerDocumentation)
.Select(x => (bool?)x.IsEnabled)
.SingleOrDefaultAsync(context.RequestAborted) ?? false;
if (!isEnabled)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
}
await next();
});
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint(
$"/swagger/{swaggerDocumentVersion}/swagger.json",
$"大学教务管理系统 API {swaggerDocumentVersion}");
options.DocExpansion(DocExpansion.None);
options.DefaultModelsExpandDepth(-1);
options.DefaultModelExpandDepth(1);
options.EnableFilter();
});
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.UseMiddleware<SlowRequestLoggingMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<AuditMiddleware>();
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<DatabaseInitializer>()
.InitializeAsync(migrateOnly);
if (seedDemoData)
{
await scope.ServiceProvider.GetRequiredService<DemoDataSeeder>()
.SeedAsync();
}
}
if (migrateOnly || seedDemoData)
{
return;
}
app.Run();
static async Task<IResult> 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<IResult> CheckCacheHealthAsync(
IServiceProvider services,
CancellationToken cancellationToken)
{
var distributedCache = services.GetService<IDistributedCache>();
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<IResult> 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;