Files
Academic-Affairs-System/src/Jiaowu.Api/Program.cs
T
biss 4d8de1e4ae “运维与审计控制台”。
主要能力:
SuperAdmin 专用入口:组织与权限 → 运维与审计。
操作日志分页查询,支持时间、账号、路径、方法和状态码筛选。
汇总自动排课、课表发布、补考安排三类失败后台任务。
实时检查数据库、缓存、任务通道及积压状态。
聚合 5xx、失败/重试任务、健康探针和备份时效告警。
SQLite 在线备份;MySQL 调用原生客户端备份。
SHA-256 校验及隔离数据库恢复演练,不覆盖业务库。
MySQL 强制使用独立运维连接,容器增加持久化备份卷与数据库客户端。
2026-07-27 16:37:45 +08:00

604 lines
22 KiB
C#

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.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.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<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 rabbitMqOptions = builder.Configuration
.GetSection(RabbitMqOptions.SectionName)
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
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 (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.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(rabbitMqOptions);
builder.Services.Configure<OfficialDocumentOptions>(
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
builder.Services.AddDbContextPool<AppDbContext>(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<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 字节的随机生产密钥,不能使用示例值。");
}
builder.Services.Configure<JwtOptions>(
builder.Configuration.GetSection(JwtOptions.SectionName));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<DemoDataSeeder>();
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services.AddScoped<TimetableDataService>();
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.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>();
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
}));
options.AddPolicy("official-verification", context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
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("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<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);
}
}
public partial class Program;