using System.Text; using System.Text.Json.Serialization; using Jiaowu.Api.Domain.Identity; 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.Persistence; using Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Timetables; using Microsoft.AspNetCore.Authentication.JwtBearer; 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 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(); 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 (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 超出允许范围。"); } builder.Services.AddSingleton(databaseOptions); builder.Services.AddSingleton(cacheOptions); builder.Services.AddDbContextPool(options => { 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); } 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.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.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services .AddAuthentication(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) }; }); 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 })); }); 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.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); } } public partial class Program;