Files
Academic-Affairs-System/src/Jiaowu.Api/Program.cs
T
2026-07-24 22:18:32 +08:00

264 lines
9.0 KiB
C#

using System.Text;
using System.Text.Json.Serialization;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Middleware;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
var databaseOptions = builder.Configuration
.GetSection(DatabaseOptions.SectionName)
.Get<DatabaseOptions>() ?? new DatabaseOptions();
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase) &&
!builder.Environment.IsDevelopment())
{
throw new InvalidOperationException("SQLite 仅允许在 Development 环境使用。生产环境请配置 MySql。");
}
builder.Services.AddDbContext<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(connectionString);
});
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)
{
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<PersonnelAccountService>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
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.AddCors(options =>
{
options.AddPolicy("Web", policy =>
{
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>()
?? ["http://localhost:5173"];
policy.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod();
});
});
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();
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.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<AuditMiddleware>();
app.MapControllers();
app.MapGet("/health", () => Results.Ok(new
{
Status = "healthy",
Database = databaseOptions.Provider,
Environment = app.Environment.EnvironmentName,
Time = DateTimeOffset.UtcNow
})).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();
}
if (args.Contains("--seed-only", StringComparer.OrdinalIgnoreCase))
{
return;
}
app.Run();
public partial class Program;