| | | 1 | | using System.IO; |
| | | 2 | | // reload trigger |
| | | 3 | | using Hangfire; |
| | | 4 | | using Hangfire.Dashboard; |
| | | 5 | | using Microsoft.AspNetCore.DataProtection; |
| | | 6 | | using Microsoft.AspNetCore.Diagnostics.HealthChecks; |
| | | 7 | | using Microsoft.AspNetCore.HttpOverrides; |
| | | 8 | | using Microsoft.AspNetCore.ResponseCompression; |
| | | 9 | | using Microsoft.AspNetCore.RateLimiting; |
| | | 10 | | using Microsoft.AspNetCore.SignalR; |
| | | 11 | | using Microsoft.AspNetCore.Mvc; |
| | | 12 | | using Microsoft.Extensions.Configuration; |
| | | 13 | | using Microsoft.Extensions.Http; |
| | | 14 | | using OzonOrdersWeb.Extensions; |
| | | 15 | | using OzonOrdersWeb.Filters; |
| | | 16 | | using OzonOrdersWeb.HostedServices; |
| | | 17 | | using OzonOrdersWeb.ModelBinding; |
| | | 18 | | using OzonOrdersWeb.Observability; |
| | | 19 | | using OzonOrdersWeb.WebServices.HangfireAuthorization; |
| | | 20 | | using OzonRepositories.Services; |
| | | 21 | | using Services.DataServices; |
| | | 22 | | using Services.ReleasServices.ReleaseManager; |
| | | 23 | | using Services.SignalRServices; |
| | | 24 | | using Serilog; |
| | | 25 | | using Serilog.Expressions; |
| | | 26 | | using Prometheus; |
| | | 27 | | using WebOptimizer; |
| | | 28 | | using NUglify.JavaScript; |
| | | 29 | | |
| | 0 | 30 | | var startupStopwatch = System.Diagnostics.Stopwatch.StartNew(); |
| | | 31 | | void StartupMark(string stage) => |
| | | 32 | | Console.WriteLine($"[startup +{startupStopwatch.Elapsed.TotalSeconds:0.0}s] {stage}"); |
| | 0 | 33 | | StartupMark("process entered Main"); |
| | | 34 | | |
| | 0 | 35 | | var builder = WebApplication.CreateBuilder(args); |
| | 0 | 36 | | StartupMark("after WebApplication.CreateBuilder"); |
| | 0 | 37 | | builder.AddRuntimeAppSettings(); |
| | 0 | 38 | | StartupMark("after AddRuntimeAppSettings"); |
| | | 39 | | |
| | | 40 | | #if DEBUG |
| | | 41 | | bool isProd = false; |
| | | 42 | | #else |
| | 0 | 43 | | bool isProd = true; |
| | | 44 | | #endif |
| | | 45 | | |
| | 0 | 46 | | string regionCode = isProd ? "en" : "ru"; |
| | | 47 | | |
| | 0 | 48 | | builder.Services.AddScoped<PimNavViewDataResultFilter>(); |
| | 0 | 49 | | builder.Services.AddScoped<InvalidModelStateLoggingFilter>(); |
| | 0 | 50 | | builder.Services.AddScoped<InvalidModelStatePageFilter>(); |
| | 0 | 51 | | builder.Services.AddScoped<BusinessFailureLoggingFilter>(); |
| | 0 | 52 | | builder.Services.AddScoped<SupplierQuotationRawResponseFilter>(); |
| | 0 | 53 | | var mvcBuilder = builder.Services.AddControllersWithViews(options => |
| | 0 | 54 | | { |
| | 0 | 55 | | // Навигации Order на формах транзакций приходят только по Id — их поля не валидируем (#1644, #1630, #1526) |
| | 0 | 56 | | options.ModelMetadataDetailsProviders.Add(new OrderNavigationValidationMetadataProvider()); |
| | 0 | 57 | | options.Filters.AddService<PimNavViewDataResultFilter>(); |
| | 0 | 58 | | options.Filters.AddService<InvalidModelStateLoggingFilter>(); |
| | 0 | 59 | | options.Filters.AddService<BusinessFailureLoggingFilter>(); |
| | 0 | 60 | | options.Filters.AddService<SupplierQuotationRawResponseFilter>(); |
| | 0 | 61 | | }); |
| | 0 | 62 | | builder.Services.AddRazorPages(options => |
| | 0 | 63 | | options.Conventions.ConfigureFilter( |
| | 0 | 64 | | new ServiceFilterAttribute(typeof(InvalidModelStatePageFilter)))); |
| | 0 | 65 | | StartupMark("after AddControllersWithViews"); |
| | | 66 | | |
| | 0 | 67 | | if (builder.Environment.IsDevelopment() || !builder.Environment.IsProduction()) |
| | | 68 | | { |
| | 0 | 69 | | mvcBuilder.AddRazorRuntimeCompilation(); |
| | | 70 | | } |
| | 0 | 71 | | builder.Services.AddHttpClient(); |
| | 0 | 72 | | builder.Services.Configure<ObservabilityOptions>( |
| | 0 | 73 | | builder.Configuration.GetSection(ObservabilityOptions.SectionName)); |
| | 0 | 74 | | builder.Services.AddSingleton<SensitiveDataRedactor>(); |
| | 0 | 75 | | builder.Services.AddTransient<OutboundRequestLoggingHandler>(); |
| | 0 | 76 | | builder.Services.ConfigureHttpClientDefaults(client => |
| | 0 | 77 | | client.AddHttpMessageHandler<OutboundRequestLoggingHandler>()); |
| | 0 | 78 | | builder.Services.AddHealthChecks() |
| | 0 | 79 | | .AddCheck<ApplicationReadyHealthCheck>("primary-database"); |
| | 0 | 80 | | StartupMark("after AddHttpClient"); |
| | | 81 | | |
| | 0 | 82 | | var dataProtectionKeysPath = builder.Configuration["DataProtection:KeysPath"]; |
| | 0 | 83 | | if (string.IsNullOrWhiteSpace(dataProtectionKeysPath)) |
| | | 84 | | { |
| | 0 | 85 | | dataProtectionKeysPath = Environment.GetEnvironmentVariable("DATA_PROTECTION_KEYS_PATH"); |
| | | 86 | | } |
| | | 87 | | |
| | 0 | 88 | | if (string.IsNullOrWhiteSpace(dataProtectionKeysPath)) |
| | | 89 | | { |
| | 0 | 90 | | dataProtectionKeysPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtection-Keys"); |
| | | 91 | | } |
| | | 92 | | |
| | 0 | 93 | | Directory.CreateDirectory(dataProtectionKeysPath); |
| | | 94 | | |
| | 0 | 95 | | builder.Services.AddDataProtection() |
| | 0 | 96 | | .SetApplicationName(builder.Configuration["DataProtection:ApplicationName"] ?? "Studio2Prod") |
| | 0 | 97 | | .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath)) |
| | 0 | 98 | | .SetDefaultKeyLifetime(TimeSpan.FromDays(90)); |
| | 0 | 99 | | StartupMark("after AddDataProtection"); |
| | | 100 | | |
| | | 101 | | // Добавляем сервисы безопасности |
| | | 102 | | //builder.Services.AddSecurityServices(isProd); |
| | | 103 | | |
| | 0 | 104 | | StartupMark("services: before AddDatabaseServices"); |
| | 0 | 105 | | builder.Services.AddDatabaseServices(builder.Configuration, isProd); |
| | 0 | 106 | | StartupMark("services: before AddHangfireServices"); |
| | 0 | 107 | | builder.Services.AddHangfireServices(builder.Configuration, isProd); |
| | 0 | 108 | | builder.Services.AddHostedService<HangfireStartupHostedService>(); |
| | 0 | 109 | | builder.Services.AddHostedService<HangfireSchedulerWatchdogHostedService>(); |
| | 0 | 110 | | builder.Services.AddHostedService<OrderSearchStartupHostedService>(); |
| | 0 | 111 | | StartupMark("services: before AddIdentityServices"); |
| | 0 | 112 | | builder.Services.AddIdentityServices(builder.Environment, builder.Configuration); |
| | 0 | 113 | | builder.Services.AddPartnerOffice(builder.Environment.IsProduction()); |
| | 0 | 114 | | builder.Services.AddRepositories(builder.Configuration); |
| | 0 | 115 | | builder.Services.AddCacheServices(); |
| | 0 | 116 | | StartupMark("services: before AddApiClients"); |
| | 0 | 117 | | builder.Services.AddApiClients(builder.Configuration); |
| | 0 | 118 | | StartupMark("services: before AddApplicationServices"); |
| | 0 | 119 | | builder.Services.AddApplicationServices(builder.Configuration, isProd); |
| | 0 | 120 | | builder.Services.AddSignalRServices(); |
| | 0 | 121 | | builder.Services.AddAppAuthorization(builder.Configuration); |
| | | 122 | | |
| | 0 | 123 | | builder.Services.Configure<OzonOrdersWeb.Api.OzonHandoverOptions>( |
| | 0 | 124 | | builder.Configuration.GetSection(OzonOrdersWeb.Api.OzonHandoverOptions.SectionName)); |
| | 0 | 125 | | builder.Services.AddEndpointsApiExplorer(); |
| | 0 | 126 | | builder.Services.AddStudio2Swagger(); |
| | | 127 | | |
| | 0 | 128 | | StartupMark("services: registered"); |
| | | 129 | | |
| | 0 | 130 | | builder.Services.AddSingleton<ReleaseManager>(provider => |
| | 0 | 131 | | { |
| | 0 | 132 | | var releaseManager = ReleaseManager.Instance; |
| | 0 | 133 | | releaseManager.SetRegionCode(regionCode); |
| | 0 | 134 | | return releaseManager; |
| | 0 | 135 | | }); |
| | | 136 | | |
| | | 137 | | // Регистрация DatabaseHealthChecker |
| | 0 | 138 | | builder.Services.AddSingleton(provider => new Services.ApiServices.DatabaseHealthChecker( |
| | 0 | 139 | | provider, |
| | 0 | 140 | | builder.Configuration, |
| | 0 | 141 | | isProd)); |
| | | 142 | | |
| | 0 | 143 | | builder.WebHost.ConfigureKestrel(serverOptions => { serverOptions.Limits.MaxRequestBodySize = 52428800; }); |
| | | 144 | | |
| | 0 | 145 | | var errorLogRootPath = builder.Configuration["AppErrorLogging:RootPath"] |
| | 0 | 146 | | ?? Path.Combine(builder.Environment.ContentRootPath, "logs"); |
| | | 147 | | |
| | 0 | 148 | | Log.Logger = new LoggerConfiguration() |
| | 0 | 149 | | .MinimumLevel.Information() |
| | 0 | 150 | | // Инфраструктурный шум (SQL-команды EF, журнал каждого HTTP-запроса) — только с уровня Warning. |
| | 0 | 151 | | // Иначе при нагрузке лог пишет десятки тысяч строк в минуту (лишний IO/CPU и трудно искать ошибки). |
| | 0 | 152 | | .MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning) |
| | 0 | 153 | | .MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning) |
| | 0 | 154 | | .MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning) |
| | 0 | 155 | | .MinimumLevel.Override("Microsoft.Hosting.Lifetime", Serilog.Events.LogEventLevel.Information) |
| | 0 | 156 | | .MinimumLevel.Override("System.Net.Http.HttpClient", Serilog.Events.LogEventLevel.Warning) |
| | 0 | 157 | | .Enrich.FromLogContext() |
| | 0 | 158 | | .Enrich.WithProperty("Application", "Studio2") |
| | 0 | 159 | | .Enrich.WithProperty("Environment", builder.Environment.EnvironmentName) |
| | 0 | 160 | | .Enrich.WithProperty("HostName", Environment.MachineName) |
| | 0 | 161 | | .WriteTo.Console(standardErrorFromLevel: Serilog.Events.LogEventLevel.Verbose) |
| | 0 | 162 | | .WriteTo.Seq(builder.Configuration["Observability:SeqUrl"] ?? "http://seq:5341") |
| | 0 | 163 | | // Файловый архив ошибок на RAID1 (.111) — переживает пересоздание контейнера/Seq, доступен через Filebrowser. |
| | 0 | 164 | | // По этому файлу studio2_log_watcher.py заводит GitHub issue на каждую уникальную запись, поэтому |
| | 0 | 165 | | // из него отсеиваются транзиентный шум чужой инфраструктуры и метрики «медленно, но успешно» |
| | 0 | 166 | | // (см. InfrastructureNoiseLogFilter). В консоль и Seq такие записи по-прежнему уходят как есть. |
| | 0 | 167 | | .WriteTo.Logger(errorFile => errorFile |
| | 0 | 168 | | .Filter.ByExcluding(OzonOrdersWeb.Observability.InfrastructureNoiseLogFilter.ShouldExcludeFromErrorLog) |
| | 0 | 169 | | .WriteTo.File( |
| | 0 | 170 | | Path.Combine(errorLogRootPath, "errors-.log"), |
| | 0 | 171 | | rollingInterval: RollingInterval.Day, |
| | 0 | 172 | | retainedFileCountLimit: 30, |
| | 0 | 173 | | shared: true, |
| | 0 | 174 | | outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj} | props={Properties:j |
| | 0 | 175 | | restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Warning) |
| | 0 | 176 | | .CreateLogger(); |
| | | 177 | | |
| | 0 | 178 | | AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => |
| | 0 | 179 | | Log.Fatal(eventArgs.ExceptionObject as Exception, |
| | 0 | 180 | | "Необработанное исключение процесса. Завершение={IsTerminating}", eventArgs.IsTerminating); |
| | 0 | 181 | | TaskScheduler.UnobservedTaskException += (_, eventArgs) => |
| | 0 | 182 | | { |
| | 0 | 183 | | Log.Error(eventArgs.Exception, "Необработанное исключение фоновой Task"); |
| | 0 | 184 | | eventArgs.SetObserved(); |
| | 0 | 185 | | }; |
| | | 186 | | |
| | 0 | 187 | | builder.Services.AddResponseCaching(); |
| | 0 | 188 | | builder.Host.UseSerilog(); |
| | | 189 | | |
| | | 190 | | // Сборка/минификация статики: минифицируем все CSS/JS «по месту» (index.js 349 КБ → ~180 КБ), |
| | | 191 | | // плюс один бандл слоя _Layout вместо ~14 отдельных CSS-запросов на каждой странице. |
| | | 192 | | // WebOptimizer сам ставит content-hash в ?v= и immutable-кеш — надёжнее ручного asp-append-version. |
| | 0 | 193 | | builder.Services.AddWebOptimizer(pipeline => |
| | 0 | 194 | | { |
| | 0 | 195 | | pipeline.AddCssBundle("/css/layout.bundle.css", |
| | 0 | 196 | | "css/design-tokens.css", |
| | 0 | 197 | | "css/base.css", |
| | 0 | 198 | | "css/app-toast.css", |
| | 0 | 199 | | "css/system-notification-modal.css", |
| | 0 | 200 | | "css/server-availability.css", |
| | 0 | 201 | | "css/sidebar.css", |
| | 0 | 202 | | "css/sidebar-quartz.css", |
| | 0 | 203 | | "css/filters.css", |
| | 0 | 204 | | "css/responsive-tables.css", |
| | 0 | 205 | | "css/custom-select.css", |
| | 0 | 206 | | "css/site.css", |
| | 0 | 207 | | "css/pagination.css", |
| | 0 | 208 | | "css/tooltip.css", |
| | 0 | 209 | | "css/status-color-badge.css", |
| | 0 | 210 | | "css/Studio2/OrderControl/order-control-notification-menu.css", |
| | 0 | 211 | | "css/Studio2/OrderDailySummary/order-daily-summary.css"); |
| | 0 | 212 | | |
| | 0 | 213 | | pipeline.MinifyCssFiles(); |
| | 0 | 214 | | // KeepAll: не переименовывать локальные переменные. NUglify в связке с вложенными |
| | 0 | 215 | | // function-декларациями после const/let (напр. table-sync.js) ломал код («r is not defined»). |
| | 0 | 216 | | // Пробел/комментарии/мёртвый код всё равно вырезаются — index.js 349 КБ → ~230 КБ, без риска. |
| | 0 | 217 | | pipeline.MinifyJsFiles(new CodeSettings |
| | 0 | 218 | | { |
| | 0 | 219 | | LocalRenaming = LocalRenaming.KeepAll, |
| | 0 | 220 | | ReorderScopeDeclarations = false |
| | 0 | 221 | | }); |
| | 0 | 222 | | }); |
| | | 223 | | |
| | 0 | 224 | | builder.Services.AddResponseCompression(options => |
| | 0 | 225 | | { |
| | 0 | 226 | | options.EnableForHttps = true; |
| | 0 | 227 | | options.Providers.Add<BrotliCompressionProvider>(); |
| | 0 | 228 | | options.Providers.Add<GzipCompressionProvider>(); |
| | 0 | 229 | | }); |
| | | 230 | | |
| | 0 | 231 | | builder.Services.Configure<BrotliCompressionProviderOptions>(options => |
| | 0 | 232 | | { |
| | 0 | 233 | | options.Level = System.IO.Compression.CompressionLevel.Fastest; |
| | 0 | 234 | | }); |
| | | 235 | | |
| | 0 | 236 | | builder.Services.Configure<GzipCompressionProviderOptions>(options => |
| | 0 | 237 | | { |
| | 0 | 238 | | options.Level = System.IO.Compression.CompressionLevel.Optimal; |
| | 0 | 239 | | }); |
| | 0 | 240 | | StartupMark("before builder.Build()"); |
| | 0 | 241 | | var app = builder.Build(); |
| | 0 | 242 | | StartupMark("after builder.Build()"); |
| | | 243 | | |
| | | 244 | | // Режим наката миграций: применяем и выходим, хостинг не поднимаем. |
| | | 245 | | // Вызывается шагом деплоя из того же образа и локально через |
| | | 246 | | // `dotnet run --project OzonOrdersWeb -- --migrate-database`. |
| | 0 | 247 | | if (DatabaseMigrationExtensions.IsMigrateDatabaseRun(args)) |
| | | 248 | | { |
| | 0 | 249 | | return await app.MigrateDatabaseAsync(); |
| | | 250 | | } |
| | | 251 | | |
| | | 252 | | // Инициализация NotificationService |
| | | 253 | | try |
| | | 254 | | { |
| | 0 | 255 | | var hubContext = app.Services.GetRequiredService<IHubContext<NotificationHub>>(); |
| | 0 | 256 | | NotificationService.Initialize(hubContext); |
| | 0 | 257 | | } |
| | 0 | 258 | | catch (Exception ex) |
| | | 259 | | { |
| | 0 | 260 | | Log.Error(ex, "Ошибка инициализации NotificationService"); |
| | 0 | 261 | | } |
| | | 262 | | |
| | 0 | 263 | | var forwardedHeadersOptions = new ForwardedHeadersOptions |
| | 0 | 264 | | { |
| | 0 | 265 | | ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHo |
| | 0 | 266 | | RequireHeaderSymmetry = false |
| | 0 | 267 | | }; |
| | | 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. |
| | 0 | 270 | | forwardedHeadersOptions.KnownNetworks.Clear(); |
| | 0 | 271 | | forwardedHeadersOptions.KnownProxies.Clear(); |
| | 0 | 272 | | app.UseForwardedHeaders(forwardedHeadersOptions); |
| | | 273 | | |
| | 0 | 274 | | if (!app.Environment.IsDevelopment()) |
| | | 275 | | { |
| | 0 | 276 | | app.UseExceptionHandler(new ExceptionHandlerOptions |
| | 0 | 277 | | { |
| | 0 | 278 | | ExceptionHandlingPath = "/Home/Error", |
| | 0 | 279 | | AllowStatusCode404Response = true |
| | 0 | 280 | | }); |
| | 0 | 281 | | app.UseStatusCodePagesWithReExecute("/Home/Error", "?statusCode={0}"); |
| | | 282 | | } |
| | | 283 | | else |
| | | 284 | | { |
| | 0 | 285 | | app.UseDeveloperExceptionPage(); |
| | | 286 | | } |
| | | 287 | | |
| | 0 | 288 | | app.UseMiddleware<RequestObservabilityMiddleware>(); |
| | 0 | 289 | | app.UseResponseCaching(); |
| | 0 | 290 | | app.UseResponseCompression(); |
| | | 291 | | // WebOptimizer обрабатывает /css/*.css, /js/*.js и бандлы — обязательно ДО UseStaticFiles. |
| | 0 | 292 | | app.UseWebOptimizer(); |
| | 0 | 293 | | app.UseStaticFiles(new StaticFileOptions |
| | 0 | 294 | | { |
| | 0 | 295 | | OnPrepareResponse = ctx => { ctx.Context.Response.Headers.Append("Cache-Control", "public,max-age=604800"); } |
| | 0 | 296 | | }); |
| | | 297 | | |
| | | 298 | | // Middleware безопасности (HSTS, Security Headers, Rate Limiting) |
| | | 299 | | ///app.UseSecurityMiddleware(isProd); |
| | | 300 | | |
| | 0 | 301 | | app.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 | | |
| | 0 | 309 | | app.UseMetricServer(); |
| | 0 | 310 | | app.UseHttpMetrics(); |
| | | 311 | | |
| | | 312 | | // Имперсонация: middleware для обработки запросов, попадающих под /Impersonation |
| | 0 | 313 | | app.UseMiddleware<OzonOrdersWeb.Middleware.ImpersonationMiddleware>(); |
| | | 314 | | |
| | 0 | 315 | | app.UseRouting(); |
| | | 316 | | |
| | 0 | 317 | | app.UseAuthentication(); |
| | 0 | 318 | | app.UseMiddleware<OzonOrdersWeb.Middleware.UserScopeInitializerMiddleware>(); |
| | 0 | 319 | | app.UseAuthorization(); |
| | 0 | 320 | | app.UseRateLimiter(); |
| | | 321 | | |
| | | 322 | | // Корень «/» → Studio2/OrdersGrid (query string сохраняется). Подмена Path здесь не работает: |
| | | 323 | | // endpoint уже выбран в UseRouting по дефолтному маршруту (Orders/Index), поэтому нужен редирект. |
| | 0 | 324 | | app.Use(async (context, next) => |
| | 0 | 325 | | { |
| | 0 | 326 | | if (context.Request.Path.HasValue && context.Request.Path.Value == "/") |
| | 0 | 327 | | { |
| | 0 | 328 | | if (context.User?.Identity?.IsAuthenticated != true) |
| | 0 | 329 | | { |
| | 0 | 330 | | context.Response.Redirect("/Identity/Account/Login"); |
| | 0 | 331 | | return; |
| | 0 | 332 | | } |
| | 0 | 333 | | |
| | 0 | 334 | | context.Response.Redirect("/Studio2/OrdersGrid" + context.Request.QueryString); |
| | 0 | 335 | | return; |
| | 0 | 336 | | } |
| | 0 | 337 | | |
| | 0 | 338 | | await next(); |
| | 0 | 339 | | }); |
| | | 340 | | |
| | 0 | 341 | | app.AddCustomMiddleware(); |
| | | 342 | | |
| | 0 | 343 | | app.UseHangfireDashboard("/Hangfire", new DashboardOptions |
| | 0 | 344 | | { |
| | 0 | 345 | | Authorization = [new HangfireAuthorization()] |
| | 0 | 346 | | }); |
| | | 347 | | |
| | 0 | 348 | | app.MapHub<NotificationHub>("/notificationHub"); |
| | 0 | 349 | | app.MapHub<OrderHistoryHub>("/orderHistoryHub"); |
| | 0 | 350 | | app.MapHub<JobProgressHub>("/jobProgressHub"); |
| | | 351 | | |
| | | 352 | | // Проверяем соединение с Bitrix при запуске приложения (в фоне, не блокируя запуск) |
| | 0 | 353 | | _ = Task.Run(async () => |
| | 0 | 354 | | { |
| | 0 | 355 | | try |
| | 0 | 356 | | { |
| | 0 | 357 | | await Task.Delay(TimeSpan.FromSeconds(2)); // Небольшая задержка для инициализации сервисов |
| | 0 | 358 | | var connectionService = app.Services.GetRequiredService<IBitrixConnectionService>(); |
| | 0 | 359 | | var isAvailable = await connectionService.IsConnectionAvailableAsync(); |
| | 0 | 360 | | Log.Information("Bitrix connection check on startup: {Status}", isAvailable ? "Available" : "Unavailable"); |
| | 0 | 361 | | } |
| | 0 | 362 | | catch (Exception ex) |
| | 0 | 363 | | { |
| | 0 | 364 | | Log.Warning(ex, "Failed to check Bitrix connection on startup"); |
| | 0 | 365 | | } |
| | 0 | 366 | | }); |
| | | 367 | | |
| | | 368 | | // Проверяем подключения к базам данных при запуске приложения |
| | 0 | 369 | | _ = Task.Run(async () => |
| | 0 | 370 | | { |
| | 0 | 371 | | try |
| | 0 | 372 | | { |
| | 0 | 373 | | await Task.Delay(TimeSpan.FromSeconds(4)); // Задержка после проверки API |
| | 0 | 374 | | var databaseHealthChecker = app.Services.GetRequiredService<Services.ApiServices.DatabaseHealthChecker>(); |
| | 0 | 375 | | await databaseHealthChecker.CheckAndPrintDatabaseHealthAsync(); |
| | 0 | 376 | | } |
| | 0 | 377 | | catch (Exception ex) |
| | 0 | 378 | | { |
| | 0 | 379 | | Log.Warning(ex, "Failed to check database health on startup"); |
| | 0 | 380 | | } |
| | 0 | 381 | | }); |
| | | 382 | | |
| | | 383 | | // Синхронизация прав (скан Razor-страниц, чтение вьюх, sync полей) — тяжёлая и |
| | | 384 | | // не нужна для приёма запросов: роли/пользователи/права на существующие страницы |
| | | 385 | | // уже в БД. Выполняем в фоне после открытия HTTP-порта, чтобы деплой не держал |
| | | 386 | | // сервис в 502 лишние минуты. Новые страницы/права станут доступны через ~минуту. |
| | 0 | 387 | | app.Lifetime.ApplicationStarted.Register(() => |
| | 0 | 388 | | { |
| | 0 | 389 | | _ = Task.Run(async () => |
| | 0 | 390 | | { |
| | 0 | 391 | | try |
| | 0 | 392 | | { |
| | 0 | 393 | | var seedStopwatch = System.Diagnostics.Stopwatch.StartNew(); |
| | 0 | 394 | | await app.UseAppAuthorizationSeedAsync(); |
| | 0 | 395 | | Log.Information("Authorization seed завершён за {Elapsed:0.0}s", seedStopwatch.Elapsed.TotalSeconds); |
| | 0 | 396 | | } |
| | 0 | 397 | | catch (Exception ex) |
| | 0 | 398 | | { |
| | 0 | 399 | | Log.Warning(ex, "Failed to seed authorization system"); |
| | 0 | 400 | | } |
| | 0 | 401 | | }); |
| | 0 | 402 | | }); |
| | | 403 | | |
| | 0 | 404 | | app.MapRazorPages(); |
| | | 405 | | |
| | 0 | 406 | | app.MapHealthChecks("/health/live", new HealthCheckOptions |
| | 0 | 407 | | { |
| | 0 | 408 | | Predicate = _ => false |
| | 0 | 409 | | }).AllowAnonymous(); |
| | 0 | 410 | | app.MapHealthChecks("/health/ready").AllowAnonymous(); |
| | | 411 | | |
| | | 412 | | // Проба доступности для клиентского server-availability.js. ShortCircuit — минуя auth/session/ |
| | | 413 | | // rate-limiter: после рестарта сюда одновременно постучатся все открытые вкладки. |
| | | 414 | | // instanceId меняется при каждом старте процесса — по нему клиент понимает, что приложение обновилось. |
| | 0 | 415 | | app.MapGet("/health/instance", (HttpContext context) => |
| | 0 | 416 | | { |
| | 0 | 417 | | context.Response.Headers.CacheControl = "no-store, no-cache, must-revalidate"; |
| | 0 | 418 | | return Results.Json(new |
| | 0 | 419 | | { |
| | 0 | 420 | | instanceId = AppInstanceInfo.InstanceId, |
| | 0 | 421 | | commit = AppInstanceInfo.Commit, |
| | 0 | 422 | | startedAtUtc = AppInstanceInfo.StartedAtUtc |
| | 0 | 423 | | }); |
| | 0 | 424 | | }).AllowAnonymous().ShortCircuit(); |
| | | 425 | | |
| | | 426 | | // swagger.json открыт без авторизации — справочник контракта для внешних интеграций |
| | | 427 | | // (например, импорта из приложения "Telegram Lite"). Сами endpoint'ы защищены отдельно |
| | | 428 | | // (см. OzonHandoverApiKeyFilter). |
| | 0 | 429 | | app.UseSwagger(); |
| | 0 | 430 | | app.UseSwaggerUI(options => |
| | 0 | 431 | | { |
| | 0 | 432 | | options.SwaggerEndpoint("/swagger/v1/swagger.json", "Studio2Prod API v1"); |
| | 0 | 433 | | options.SwaggerEndpoint($"/swagger/{OzonOrdersWeb.Extensions.PartnerOfficeExtensions.SwaggerDocName}/swagger.json", |
| | 0 | 434 | | options.RoutePrefix = "swagger"; |
| | 0 | 435 | | }); |
| | | 436 | | |
| | | 437 | | // Вход без сессии: /Identity/Account/Login. Аутентифицированный «/» обрабатывается rewrite выше. |
| | 0 | 438 | | app.MapControllers(); |
| | | 439 | | |
| | 0 | 440 | | app.MapAreaControllerRoute( |
| | 0 | 441 | | name: "public", |
| | 0 | 442 | | areaName: "Public", |
| | 0 | 443 | | pattern: "public/{controller=AssemblyList}/{action=Index}/{accessKey?}"); |
| | | 444 | | |
| | 0 | 445 | | app.MapControllerRoute( |
| | 0 | 446 | | name: "default", |
| | 0 | 447 | | pattern: "{controller=Orders}/{action=Index}/{id?}", |
| | 0 | 448 | | defaults: new { area = "Studio2" }); |
| | | 449 | | |
| | 0 | 450 | | app.MapControllerRoute( |
| | 0 | 451 | | name: "areas", |
| | 0 | 452 | | pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}"); |
| | | 453 | | |
| | | 454 | | //app.UseExceptionHandler("/Home/Error"); |
| | | 455 | | |
| | 0 | 456 | | StartupMark("before app.Run() — Kestrel starts listening"); |
| | | 457 | | try |
| | | 458 | | { |
| | 0 | 459 | | app.Run(); |
| | 0 | 460 | | } |
| | 0 | 461 | | catch (Exception exception) |
| | | 462 | | { |
| | 0 | 463 | | Log.Fatal(exception, "Studio2 аварийно завершил работу"); |
| | 0 | 464 | | throw; |
| | | 465 | | } |
| | | 466 | | finally |
| | | 467 | | { |
| | 0 | 468 | | Log.CloseAndFlush(); |
| | | 469 | | } |
| | | 470 | | |
| | 0 | 471 | | return 0; |