将候补迁移改为可重入:先检查列和索引是否存在,再执行 DDL。 附件中已经成功添加 WaitlistedAt、但索引创建失败的数据库可直接重试,不会报重复列。 运行时和 dotnet ef 连接统一开启 AllowUserVariables;Connector/NET 默认关闭该选项。Connector/NET 官方说明 增加所有 MySQL 索引名不得超过 64 字符的回归测试。
452 lines
16 KiB
C#
452 lines
16 KiB
C#
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<DatabaseOptions>() ?? new DatabaseOptions();
|
|
var cacheOptions = builder.Configuration
|
|
.GetSection(AppCacheOptions.SectionName)
|
|
.Get<AppCacheOptions>() ?? 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<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<AutomaticScheduleJobProcessor>();
|
|
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
|
|
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
|
|
builder.Services.AddScoped<SchedulePlanPublisher>();
|
|
builder.Services.AddScoped<SchedulePublishJobProcessor>();
|
|
builder.Services.AddSingleton<SchedulePublishJobQueue>();
|
|
builder.Services.AddHostedService<SchedulePublishJobWorker>();
|
|
builder.Services.AddHostedService<WarningCheckWorker>();
|
|
builder.Services.AddScoped<ExamArrangementService>();
|
|
builder.Services.AddScoped<MakeupExamEligibilityService>();
|
|
builder.Services.AddScoped<MakeupExamArrangementService>();
|
|
builder.Services.AddSingleton<MakeupExamAutoJobQueue>();
|
|
builder.Services.AddHostedService<MakeupExamAutoJobWorker>();
|
|
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
|
|
|
|
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<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.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);
|
|
}
|
|
}
|
|
|
|
public partial class Program;
|