using System.Text.Json.Serialization; using Extensions.Hosting.AsyncInitialization; using Hangfire; using Hangfire.PostgreSql; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Microsoft.OpenApi; using Scalar.AspNetCore; using Serilog; using Tnsu.ApprovalRoute.Api.Auth; using Tnsu.ApprovalRoute.Api.Filters; using Tnsu.ApprovalRoute.Application; using Tnsu.ApprovalRoute.Application.Approvals; using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.Reminders; using Tnsu.ApprovalRoute.Application.Reports; using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Infrastructure; const string CorsPolicyName = "Frontend"; var builder = WebApplication.CreateBuilder(args); // Локальные оверрайды, не попадающие в git (см. .gitignore) — на своей машине // можно, например, занизить ListPartitioning:ItemCountThreshold, чтобы не // накапливать тысячи тестовых заявок для проверки создания партиций. builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); builder.Host.UseSerilog((context, services, configuration) => configuration .ReadFrom.Configuration(context.Configuration) .Enrich.FromLogContext() .WriteTo.Console() .WriteTo.File( "logs/log-.txt", rollingInterval: RollingInterval.Month, retainedFileCountLimit: 6)); builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); }); var corsAllowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; builder.Services.AddCors(options => { options.AddPolicy(CorsPolicyName, policy => policy .WithOrigins(corsAllowedOrigins) .AllowAnyHeader() .AllowAnyMethod() // Без этого браузер не отдаёт скрипту Content-Disposition кросс-доменного ответа, // а из него фронт берёт имя скачиваемого файла (выгрузка маршрутов). .WithExposedHeaders("Content-Disposition")); }); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteApplication(); builder.Services.Configure(builder.Configuration.GetSection("Reminders")); builder.Services.Configure(builder.Configuration.GetSection("ListPartitioning")); builder.Services.Configure(builder.Configuration.GetSection("Reports")); builder.Services.Configure(builder.Configuration.GetSection("AutoApprove")); builder.Services.AddHangfire(configuration => configuration .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() .UsePostgreSqlStorage(postgres => postgres.UseNpgsqlConnection(builder.Configuration.GetConnectionString("Postgres")))); builder.Services.AddHangfireServer(); builder.Services.AddApprovalRouteAuth(builder.Configuration); builder.Services.AddScoped(); builder.Services.AddOpenApi(options => { options.AddDocumentTransformer((document, _, _) => { document.Components ??= new OpenApiComponents(); document.Components.SecuritySchemes ??= new Dictionary(); document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme { Type = SecuritySchemeType.Http, Scheme = "bearer", BearerFormat = "JWT", Description = "Введите JWT токен" }; return Task.CompletedTask; }); options.AddOperationTransformer((operation, context, _) => { operation.Security = [ new OpenApiSecurityRequirement { [new OpenApiSecuritySchemeReference("Bearer", context.Document)] = [] } ]; return Task.CompletedTask; }); }); builder.Services.AddHealthChecks(); builder.Services.AddControllers(options => options.Filters.Add()) .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); app.UseForwardedHeaders(); app.MapOpenApi(); app.MapScalarApiReference(options => { options .WithTitle("Tnsu Approval Route") .AddPreferredSecuritySchemes("Bearer") .AddHttpAuthentication("Bearer", _ => { }); }); app.MapHealthChecks("/health"); app.UseCors(CorsPolicyName); app.UseAuthentication(); app.UseAuthorization(); app.UseHangfireDashboard("/hangfire", new DashboardOptions { Authorization = [ new HangfireDashboardBasicAuthFilter( app.Configuration["Hangfire:Dashboard:Username"] ?? string.Empty, app.Configuration["Hangfire:Dashboard:Password"] ?? string.Empty) ] }); // Рекуррентная задача регистрируется ПОСЛЕ Build (нужен собранный контейнер) и // на каждом старте: AddOrUpdate идемпотентен и заодно подхватывает изменившееся // расписание из конфигурации — иначе задача навсегда осталась бы с тем cron, с // которым её создали в первый раз. var autoApprove = app.Services.GetRequiredService>().Value; var recurringJobs = app.Services.GetRequiredService(); if (autoApprove.Enabled) { recurringJobs.AddOrUpdate( AutoApproveJob.RecurringJobId, job => job.RunAsync(CancellationToken.None), autoApprove.Cron, // Cron в настройке — в UTC, как и всё остальное время в сервисе; иначе // расписание поехало бы при смене часового пояса машины. new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); } else { // Выключили настройкой — снимаем задачу, а не оставляем её вхолостую крутиться. recurringJobs.RemoveIfExists(AutoApproveJob.RecurringJobId); } app.MapControllers(); await app.InitAndRunAsync(); public partial class Program;