< Summary

Information
Class: Program
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Program.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 322
Coverable lines: 322
Total lines: 471
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 22
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
<Main>$()0%506220%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Program.cs

#LineLine coverage
 1using System.IO;
 2// reload trigger
 3using Hangfire;
 4using Hangfire.Dashboard;
 5using Microsoft.AspNetCore.DataProtection;
 6using Microsoft.AspNetCore.Diagnostics.HealthChecks;
 7using Microsoft.AspNetCore.HttpOverrides;
 8using Microsoft.AspNetCore.ResponseCompression;
 9using Microsoft.AspNetCore.RateLimiting;
 10using Microsoft.AspNetCore.SignalR;
 11using Microsoft.AspNetCore.Mvc;
 12using Microsoft.Extensions.Configuration;
 13using Microsoft.Extensions.Http;
 14using OzonOrdersWeb.Extensions;
 15using OzonOrdersWeb.Filters;
 16using OzonOrdersWeb.HostedServices;
 17using OzonOrdersWeb.ModelBinding;
 18using OzonOrdersWeb.Observability;
 19using OzonOrdersWeb.WebServices.HangfireAuthorization;
 20using OzonRepositories.Services;
 21using Services.DataServices;
 22using Services.ReleasServices.ReleaseManager;
 23using Services.SignalRServices;
 24using Serilog;
 25using Serilog.Expressions;
 26using Prometheus;
 27using WebOptimizer;
 28using NUglify.JavaScript;
 29
 030var startupStopwatch = System.Diagnostics.Stopwatch.StartNew();
 31void StartupMark(string stage) =>
 32    Console.WriteLine($"[startup +{startupStopwatch.Elapsed.TotalSeconds:0.0}s] {stage}");
 033StartupMark("process entered Main");
 34
 035var builder = WebApplication.CreateBuilder(args);
 036StartupMark("after WebApplication.CreateBuilder");
 037builder.AddRuntimeAppSettings();
 038StartupMark("after AddRuntimeAppSettings");
 39
 40#if DEBUG
 41bool isProd = false;
 42#else
 043bool isProd = true;
 44#endif
 45
 046string regionCode = isProd ? "en" : "ru";
 47
 048builder.Services.AddScoped<PimNavViewDataResultFilter>();
 049builder.Services.AddScoped<InvalidModelStateLoggingFilter>();
 050builder.Services.AddScoped<InvalidModelStatePageFilter>();
 051builder.Services.AddScoped<BusinessFailureLoggingFilter>();
 052builder.Services.AddScoped<SupplierQuotationRawResponseFilter>();
 053var mvcBuilder = builder.Services.AddControllersWithViews(options =>
 054{
 055    // Навигации Order на формах транзакций приходят только по Id — их поля не валидируем (#1644, #1630, #1526)
 056    options.ModelMetadataDetailsProviders.Add(new OrderNavigationValidationMetadataProvider());
 057    options.Filters.AddService<PimNavViewDataResultFilter>();
 058    options.Filters.AddService<InvalidModelStateLoggingFilter>();
 059    options.Filters.AddService<BusinessFailureLoggingFilter>();
 060    options.Filters.AddService<SupplierQuotationRawResponseFilter>();
 061});
 062builder.Services.AddRazorPages(options =>
 063    options.Conventions.ConfigureFilter(
 064        new ServiceFilterAttribute(typeof(InvalidModelStatePageFilter))));
 065StartupMark("after AddControllersWithViews");
 66
 067if (builder.Environment.IsDevelopment() || !builder.Environment.IsProduction())
 68{
 069    mvcBuilder.AddRazorRuntimeCompilation();
 70}
 071builder.Services.AddHttpClient();
 072builder.Services.Configure<ObservabilityOptions>(
 073    builder.Configuration.GetSection(ObservabilityOptions.SectionName));
 074builder.Services.AddSingleton<SensitiveDataRedactor>();
 075builder.Services.AddTransient<OutboundRequestLoggingHandler>();
 076builder.Services.ConfigureHttpClientDefaults(client =>
 077    client.AddHttpMessageHandler<OutboundRequestLoggingHandler>());
 078builder.Services.AddHealthChecks()
 079    .AddCheck<ApplicationReadyHealthCheck>("primary-database");
 080StartupMark("after AddHttpClient");
 81
 082var dataProtectionKeysPath = builder.Configuration["DataProtection:KeysPath"];
 083if (string.IsNullOrWhiteSpace(dataProtectionKeysPath))
 84{
 085    dataProtectionKeysPath = Environment.GetEnvironmentVariable("DATA_PROTECTION_KEYS_PATH");
 86}
 87
 088if (string.IsNullOrWhiteSpace(dataProtectionKeysPath))
 89{
 090    dataProtectionKeysPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtection-Keys");
 91}
 92
 093Directory.CreateDirectory(dataProtectionKeysPath);
 94
 095builder.Services.AddDataProtection()
 096    .SetApplicationName(builder.Configuration["DataProtection:ApplicationName"] ?? "Studio2Prod")
 097    .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath))
 098    .SetDefaultKeyLifetime(TimeSpan.FromDays(90));
 099StartupMark("after AddDataProtection");
 100
 101// Добавляем сервисы безопасности
 102//builder.Services.AddSecurityServices(isProd);
 103
 0104StartupMark("services: before AddDatabaseServices");
 0105builder.Services.AddDatabaseServices(builder.Configuration, isProd);
 0106StartupMark("services: before AddHangfireServices");
 0107builder.Services.AddHangfireServices(builder.Configuration, isProd);
 0108builder.Services.AddHostedService<HangfireStartupHostedService>();
 0109builder.Services.AddHostedService<HangfireSchedulerWatchdogHostedService>();
 0110builder.Services.AddHostedService<OrderSearchStartupHostedService>();
 0111StartupMark("services: before AddIdentityServices");
 0112builder.Services.AddIdentityServices(builder.Environment, builder.Configuration);
 0113builder.Services.AddPartnerOffice(builder.Environment.IsProduction());
 0114builder.Services.AddRepositories(builder.Configuration);
 0115builder.Services.AddCacheServices();
 0116StartupMark("services: before AddApiClients");
 0117builder.Services.AddApiClients(builder.Configuration);
 0118StartupMark("services: before AddApplicationServices");
 0119builder.Services.AddApplicationServices(builder.Configuration, isProd);
 0120builder.Services.AddSignalRServices();
 0121builder.Services.AddAppAuthorization(builder.Configuration);
 122
 0123builder.Services.Configure<OzonOrdersWeb.Api.OzonHandoverOptions>(
 0124    builder.Configuration.GetSection(OzonOrdersWeb.Api.OzonHandoverOptions.SectionName));
 0125builder.Services.AddEndpointsApiExplorer();
 0126builder.Services.AddStudio2Swagger();
 127
 0128StartupMark("services: registered");
 129
 0130builder.Services.AddSingleton<ReleaseManager>(provider =>
 0131{
 0132    var releaseManager = ReleaseManager.Instance;
 0133    releaseManager.SetRegionCode(regionCode);
 0134    return releaseManager;
 0135});
 136
 137// Регистрация DatabaseHealthChecker
 0138builder.Services.AddSingleton(provider => new Services.ApiServices.DatabaseHealthChecker(
 0139    provider,
 0140    builder.Configuration,
 0141    isProd));
 142
 0143builder.WebHost.ConfigureKestrel(serverOptions => { serverOptions.Limits.MaxRequestBodySize = 52428800; });
 144
 0145var errorLogRootPath = builder.Configuration["AppErrorLogging:RootPath"]
 0146    ?? Path.Combine(builder.Environment.ContentRootPath, "logs");
 147
 0148Log.Logger = new LoggerConfiguration()
 0149    .MinimumLevel.Information()
 0150    // Инфраструктурный шум (SQL-команды EF, журнал каждого HTTP-запроса) — только с уровня Warning.
 0151    // Иначе при нагрузке лог пишет десятки тысяч строк в минуту (лишний IO/CPU и трудно искать ошибки).
 0152    .MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
 0153    .MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
 0154    .MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
 0155    .MinimumLevel.Override("Microsoft.Hosting.Lifetime", Serilog.Events.LogEventLevel.Information)
 0156    .MinimumLevel.Override("System.Net.Http.HttpClient", Serilog.Events.LogEventLevel.Warning)
 0157    .Enrich.FromLogContext()
 0158    .Enrich.WithProperty("Application", "Studio2")
 0159    .Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
 0160    .Enrich.WithProperty("HostName", Environment.MachineName)
 0161    .WriteTo.Console(standardErrorFromLevel: Serilog.Events.LogEventLevel.Verbose)
 0162    .WriteTo.Seq(builder.Configuration["Observability:SeqUrl"] ?? "http://seq:5341")
 0163    // Файловый архив ошибок на RAID1 (.111) — переживает пересоздание контейнера/Seq, доступен через Filebrowser.
 0164    // По этому файлу studio2_log_watcher.py заводит GitHub issue на каждую уникальную запись, поэтому
 0165    // из него отсеиваются транзиентный шум чужой инфраструктуры и метрики «медленно, но успешно»
 0166    // (см. InfrastructureNoiseLogFilter). В консоль и Seq такие записи по-прежнему уходят как есть.
 0167    .WriteTo.Logger(errorFile => errorFile
 0168            .Filter.ByExcluding(OzonOrdersWeb.Observability.InfrastructureNoiseLogFilter.ShouldExcludeFromErrorLog)
 0169            .WriteTo.File(
 0170                Path.Combine(errorLogRootPath, "errors-.log"),
 0171                rollingInterval: RollingInterval.Day,
 0172                retainedFileCountLimit: 30,
 0173                shared: true,
 0174                outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj} | props={Properties:j
 0175        restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Warning)
 0176    .CreateLogger();
 177
 0178AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
 0179    Log.Fatal(eventArgs.ExceptionObject as Exception,
 0180        "Необработанное исключение процесса. Завершение={IsTerminating}", eventArgs.IsTerminating);
 0181TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
 0182{
 0183    Log.Error(eventArgs.Exception, "Необработанное исключение фоновой Task");
 0184    eventArgs.SetObserved();
 0185};
 186
 0187builder.Services.AddResponseCaching();
 0188builder.Host.UseSerilog();
 189
 190// Сборка/минификация статики: минифицируем все CSS/JS «по месту» (index.js 349 КБ → ~180 КБ),
 191// плюс один бандл слоя _Layout вместо ~14 отдельных CSS-запросов на каждой странице.
 192// WebOptimizer сам ставит content-hash в ?v= и immutable-кеш — надёжнее ручного asp-append-version.
 0193builder.Services.AddWebOptimizer(pipeline =>
 0194{
 0195    pipeline.AddCssBundle("/css/layout.bundle.css",
 0196        "css/design-tokens.css",
 0197        "css/base.css",
 0198        "css/app-toast.css",
 0199        "css/system-notification-modal.css",
 0200        "css/server-availability.css",
 0201        "css/sidebar.css",
 0202        "css/sidebar-quartz.css",
 0203        "css/filters.css",
 0204        "css/responsive-tables.css",
 0205        "css/custom-select.css",
 0206        "css/site.css",
 0207        "css/pagination.css",
 0208        "css/tooltip.css",
 0209        "css/status-color-badge.css",
 0210        "css/Studio2/OrderControl/order-control-notification-menu.css",
 0211        "css/Studio2/OrderDailySummary/order-daily-summary.css");
 0212
 0213    pipeline.MinifyCssFiles();
 0214    // KeepAll: не переименовывать локальные переменные. NUglify в связке с вложенными
 0215    // function-декларациями после const/let (напр. table-sync.js) ломал код («r is not defined»).
 0216    // Пробел/комментарии/мёртвый код всё равно вырезаются — index.js 349 КБ → ~230 КБ, без риска.
 0217    pipeline.MinifyJsFiles(new CodeSettings
 0218    {
 0219        LocalRenaming = LocalRenaming.KeepAll,
 0220        ReorderScopeDeclarations = false
 0221    });
 0222});
 223
 0224builder.Services.AddResponseCompression(options =>
 0225{
 0226    options.EnableForHttps = true;
 0227    options.Providers.Add<BrotliCompressionProvider>();
 0228    options.Providers.Add<GzipCompressionProvider>();
 0229});
 230
 0231builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
 0232{
 0233    options.Level = System.IO.Compression.CompressionLevel.Fastest;
 0234});
 235
 0236builder.Services.Configure<GzipCompressionProviderOptions>(options =>
 0237{
 0238    options.Level = System.IO.Compression.CompressionLevel.Optimal;
 0239});
 0240StartupMark("before builder.Build()");
 0241var app = builder.Build();
 0242StartupMark("after builder.Build()");
 243
 244// Режим наката миграций: применяем и выходим, хостинг не поднимаем.
 245// Вызывается шагом деплоя из того же образа и локально через
 246// `dotnet run --project OzonOrdersWeb -- --migrate-database`.
 0247if (DatabaseMigrationExtensions.IsMigrateDatabaseRun(args))
 248{
 0249    return await app.MigrateDatabaseAsync();
 250}
 251
 252// Инициализация NotificationService
 253try
 254{
 0255    var hubContext = app.Services.GetRequiredService<IHubContext<NotificationHub>>();
 0256    NotificationService.Initialize(hubContext);
 0257}
 0258catch (Exception ex)
 259{
 0260    Log.Error(ex, "Ошибка инициализации NotificationService");
 0261}
 262
 0263var forwardedHeadersOptions = new ForwardedHeadersOptions
 0264{
 0265    ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHo
 0266    RequireHeaderSymmetry = false
 0267};
 268// In container/proxy deployments nginx may come from non-loopback IP.
 269// Without clearing defaults ASP.NET ignores forwarded headers and builds http callback URLs.
 0270forwardedHeadersOptions.KnownNetworks.Clear();
 0271forwardedHeadersOptions.KnownProxies.Clear();
 0272app.UseForwardedHeaders(forwardedHeadersOptions);
 273
 0274if (!app.Environment.IsDevelopment())
 275{
 0276    app.UseExceptionHandler(new ExceptionHandlerOptions
 0277    {
 0278        ExceptionHandlingPath = "/Home/Error",
 0279        AllowStatusCode404Response = true
 0280    });
 0281    app.UseStatusCodePagesWithReExecute("/Home/Error", "?statusCode={0}");
 282}
 283else
 284{
 0285    app.UseDeveloperExceptionPage();
 286}
 287
 0288app.UseMiddleware<RequestObservabilityMiddleware>();
 0289app.UseResponseCaching();
 0290app.UseResponseCompression();
 291// WebOptimizer обрабатывает /css/*.css, /js/*.js и бандлы — обязательно ДО UseStaticFiles.
 0292app.UseWebOptimizer();
 0293app.UseStaticFiles(new StaticFileOptions
 0294{
 0295    OnPrepareResponse = ctx => { ctx.Context.Response.Headers.Append("Cache-Control", "public,max-age=604800"); }
 0296});
 297
 298// Middleware безопасности (HSTS, Security Headers, Rate Limiting)
 299///app.UseSecurityMiddleware(isProd);
 300
 0301app.UseSession();
 302
 303// Внешний Nginx выполняет SSL-терминацию и перенаправление HTTP->HTTPS на 80 порту.
 304// Kestrel внутри контейнера слушает только HTTP (:5002).
 305// Вызов UseHttpsRedirection() без настроенного HTTPS-порта в Kestrel приводил к ложным
 306// предупреждениям "Failed to determine the https port for redirect" на внутренних проверках (/health/live).
 307// app.UseHttpsRedirection();
 308
 0309app.UseMetricServer();
 0310app.UseHttpMetrics();
 311
 312// Имперсонация: middleware для обработки запросов, попадающих под /Impersonation
 0313app.UseMiddleware<OzonOrdersWeb.Middleware.ImpersonationMiddleware>();
 314
 0315app.UseRouting();
 316
 0317app.UseAuthentication();
 0318app.UseMiddleware<OzonOrdersWeb.Middleware.UserScopeInitializerMiddleware>();
 0319app.UseAuthorization();
 0320app.UseRateLimiter();
 321
 322// Корень «/» → Studio2/OrdersGrid (query string сохраняется). Подмена Path здесь не работает:
 323// endpoint уже выбран в UseRouting по дефолтному маршруту (Orders/Index), поэтому нужен редирект.
 0324app.Use(async (context, next) =>
 0325{
 0326    if (context.Request.Path.HasValue && context.Request.Path.Value == "/")
 0327    {
 0328        if (context.User?.Identity?.IsAuthenticated != true)
 0329        {
 0330            context.Response.Redirect("/Identity/Account/Login");
 0331            return;
 0332        }
 0333
 0334        context.Response.Redirect("/Studio2/OrdersGrid" + context.Request.QueryString);
 0335        return;
 0336    }
 0337
 0338    await next();
 0339});
 340
 0341app.AddCustomMiddleware();
 342
 0343app.UseHangfireDashboard("/Hangfire", new DashboardOptions
 0344{
 0345    Authorization = [new HangfireAuthorization()]
 0346});
 347
 0348app.MapHub<NotificationHub>("/notificationHub");
 0349app.MapHub<OrderHistoryHub>("/orderHistoryHub");
 0350app.MapHub<JobProgressHub>("/jobProgressHub");
 351
 352// Проверяем соединение с Bitrix при запуске приложения (в фоне, не блокируя запуск)
 0353_ = Task.Run(async () =>
 0354{
 0355    try
 0356    {
 0357        await Task.Delay(TimeSpan.FromSeconds(2)); // Небольшая задержка для инициализации сервисов
 0358        var connectionService = app.Services.GetRequiredService<IBitrixConnectionService>();
 0359        var isAvailable = await connectionService.IsConnectionAvailableAsync();
 0360        Log.Information("Bitrix connection check on startup: {Status}", isAvailable ? "Available" : "Unavailable");
 0361    }
 0362    catch (Exception ex)
 0363    {
 0364        Log.Warning(ex, "Failed to check Bitrix connection on startup");
 0365    }
 0366});
 367
 368// Проверяем подключения к базам данных при запуске приложения
 0369_ = Task.Run(async () =>
 0370{
 0371    try
 0372    {
 0373        await Task.Delay(TimeSpan.FromSeconds(4)); // Задержка после проверки API
 0374        var databaseHealthChecker = app.Services.GetRequiredService<Services.ApiServices.DatabaseHealthChecker>();
 0375        await databaseHealthChecker.CheckAndPrintDatabaseHealthAsync();
 0376    }
 0377    catch (Exception ex)
 0378    {
 0379        Log.Warning(ex, "Failed to check database health on startup");
 0380    }
 0381});
 382
 383// Синхронизация прав (скан Razor-страниц, чтение вьюх, sync полей) — тяжёлая и
 384// не нужна для приёма запросов: роли/пользователи/права на существующие страницы
 385// уже в БД. Выполняем в фоне после открытия HTTP-порта, чтобы деплой не держал
 386// сервис в 502 лишние минуты. Новые страницы/права станут доступны через ~минуту.
 0387app.Lifetime.ApplicationStarted.Register(() =>
 0388{
 0389    _ = Task.Run(async () =>
 0390    {
 0391        try
 0392        {
 0393            var seedStopwatch = System.Diagnostics.Stopwatch.StartNew();
 0394            await app.UseAppAuthorizationSeedAsync();
 0395            Log.Information("Authorization seed завершён за {Elapsed:0.0}s", seedStopwatch.Elapsed.TotalSeconds);
 0396        }
 0397        catch (Exception ex)
 0398        {
 0399            Log.Warning(ex, "Failed to seed authorization system");
 0400        }
 0401    });
 0402});
 403
 0404app.MapRazorPages();
 405
 0406app.MapHealthChecks("/health/live", new HealthCheckOptions
 0407{
 0408    Predicate = _ => false
 0409}).AllowAnonymous();
 0410app.MapHealthChecks("/health/ready").AllowAnonymous();
 411
 412// Проба доступности для клиентского server-availability.js. ShortCircuit — минуя auth/session/
 413// rate-limiter: после рестарта сюда одновременно постучатся все открытые вкладки.
 414// instanceId меняется при каждом старте процесса — по нему клиент понимает, что приложение обновилось.
 0415app.MapGet("/health/instance", (HttpContext context) =>
 0416{
 0417    context.Response.Headers.CacheControl = "no-store, no-cache, must-revalidate";
 0418    return Results.Json(new
 0419    {
 0420        instanceId = AppInstanceInfo.InstanceId,
 0421        commit = AppInstanceInfo.Commit,
 0422        startedAtUtc = AppInstanceInfo.StartedAtUtc
 0423    });
 0424}).AllowAnonymous().ShortCircuit();
 425
 426// swagger.json открыт без авторизации — справочник контракта для внешних интеграций
 427// (например, импорта из приложения "Telegram Lite"). Сами endpoint'ы защищены отдельно
 428// (см. OzonHandoverApiKeyFilter).
 0429app.UseSwagger();
 0430app.UseSwaggerUI(options =>
 0431{
 0432    options.SwaggerEndpoint("/swagger/v1/swagger.json", "Studio2Prod API v1");
 0433    options.SwaggerEndpoint($"/swagger/{OzonOrdersWeb.Extensions.PartnerOfficeExtensions.SwaggerDocName}/swagger.json", 
 0434    options.RoutePrefix = "swagger";
 0435});
 436
 437// Вход без сессии: /Identity/Account/Login. Аутентифицированный «/» обрабатывается rewrite выше.
 0438app.MapControllers();
 439
 0440app.MapAreaControllerRoute(
 0441    name: "public",
 0442    areaName: "Public",
 0443    pattern: "public/{controller=AssemblyList}/{action=Index}/{accessKey?}");
 444
 0445app.MapControllerRoute(
 0446    name: "default",
 0447    pattern: "{controller=Orders}/{action=Index}/{id?}",
 0448    defaults: new { area = "Studio2" });
 449
 0450app.MapControllerRoute(
 0451    name: "areas",
 0452    pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
 453
 454//app.UseExceptionHandler("/Home/Error");
 455
 0456StartupMark("before app.Run() — Kestrel starts listening");
 457try
 458{
 0459    app.Run();
 0460}
 0461catch (Exception exception)
 462{
 0463    Log.Fatal(exception, "Studio2 аварийно завершил работу");
 0464    throw;
 465}
 466finally
 467{
 0468    Log.CloseAndFlush();
 469}
 470
 0471return 0;

Methods/Properties

<Main>$()