865 lines
34 KiB
C#
865 lines
34 KiB
C#
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 和 FrontendBaseUrl;CallbackUrl 必须以 /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;
|