food-market/src/food-market.api/Program.cs
nns 1044818fbb
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(s10): year-demo seeder + 4 dashboard виджета + week-stats
S10-1: YearDemoSeeder — POST /api/admin/seed-demo?years=1.
- 8 групп × 25 товаров = 200, 30 контрагентов, 80 приёмок равномерно
  по году, 1500 розничных продаж с месячной сезонностью (Dec пик ×1.6,
  Jul-Aug спад ×0.7), 20 customer-returns, 8 demands, 10 losses, 3
  transfers, 5 inventories.
- Маркер артикулов Y1- (параллельно с DEMO-короткий сидер). Гард на
  существующую активность чтобы не лить хаос поверх ручной работы.
- Bulk StockMovement + переагрегация Stocks в конце транзакции —
  16.5s на dev-vm vs 60+s если бы per-document SaveChanges.

S10-2: DashboardController + 4 виджета:
- GET /api/dashboard/top-products?days&limit — top-N по gross-выручке
  (без net-вычета returns; для точного есть /api/reports/sales).
- GET /api/dashboard/low-stock?limit — Stock.Quantity ≤ Product.MinStock.
- GET /api/dashboard/recent-sales?limit — последние N посt'ed чеков.
- GET /api/dashboard/margin?days — Σ(LineTotal) - Σ(qty × Product.Cost),
  marginPercent к выручке.
- /api/sales/retail/stats расширен revenueThisWeek + transactionsThisWeek.
- Frontend: components/DashboardWidgets.tsx с 4 виджетами через
  React.lazy + Suspense. SignalR SalePosted инвалидирует все 4.
- KPI блок: today / week / month + avg-ticket (4 плитки, prev-month
  стал delta на month-плитке).

Проверено на стэйдже год-демо: top-5 за 365 дн. — «Колбаса сервелат
300г» 286440 ₸ / 32 транзакции. Margin 40% за 30 дн.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 01:03:36 +05:00

544 lines
31 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Security.Claims;
using foodmarket.Api.Storage;
using Hangfire;
using Hangfire.PostgreSql;
using Prometheus;
using FluentValidation;
using foodmarket.Api.Infrastructure.RateLimiting;
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Api.Seed;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Validation.AspNetCore;
using Serilog;
using static OpenIddict.Abstractions.OpenIddictConstants;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((ctx, services, cfg) =>
cfg.ReadFrom.Configuration(ctx.Configuration).ReadFrom.Services(services));
const string CorsPolicy = "food-market-cors";
builder.Services.AddCors(opts => opts.AddPolicy(CorsPolicy, p =>
{
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
?? ["http://localhost:5173"];
p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
}));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>();
// ClaimsTransformation для SuperAdmin override: добавляет роли Admin/
// Storekeeper/Cashier при наличии X-Org-Override, чтобы [Authorize(Roles=...)]
// не отшивал edit-mode мутации SuperAdmin'а. См. SuperAdminOverrideClaimsTransformer.
builder.Services.AddScoped<Microsoft.AspNetCore.Authentication.IClaimsTransformation,
foodmarket.Api.Infrastructure.Tenancy.SuperAdminOverrideClaimsTransformer>();
// Prometheus metrics: singleton-интерсептор EF засекает длительность каждого
// SQL-запроса (см. food_market_db_query_duration_seconds). HTTP-метрики
// включаются ниже через UseHttpMetrics(). /metrics endpoint доступен всем
// (стандартная практика для Prometheus scrape) — на prod закрываем nginx'ом.
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>();
// OrgAuditInterceptor — scoped (зависит от ITenantContext). EF тащит его
// через AddInterceptors на каждое создание DbContext (DbContext тоже scoped).
builder.Services.AddScoped<foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, opts) =>
{
opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"),
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name));
opts.UseOpenIddict();
opts.AddInterceptors(sp.GetRequiredService<
foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>());
opts.AddInterceptors(sp.GetRequiredService<
foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>());
});
builder.Services.AddIdentity<User, Role>(opts =>
{
opts.Password.RequireDigit = true;
opts.Password.RequiredLength = 8;
opts.Password.RequireNonAlphanumeric = false;
opts.Password.RequireUppercase = false;
opts.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
builder.Services.Configure<IdentityOptions>(opts =>
{
opts.ClaimsIdentity.UserNameClaimType = Claims.Name;
opts.ClaimsIdentity.UserIdClaimType = Claims.Subject;
opts.ClaimsIdentity.RoleClaimType = Claims.Role;
});
builder.Services.AddOpenIddict()
.AddCore(opts => opts.UseEntityFrameworkCore().UseDbContext<AppDbContext>())
.AddServer(opts =>
{
opts.SetTokenEndpointUris("/connect/token");
opts.AllowPasswordFlow().AllowRefreshTokenFlow();
opts.AcceptAnonymousClients();
opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api");
// Ключи подписи/шифрования: dev — persistent RSA-ключ в App_Data;
// prod/stage — X509-сертификаты из конфига (OpenIddict:SigningCertPath /
// EncryptionCertPath), persistent self-signed если файла нет.
// Подробности и dev-инвариант — в OpenIddictKeyConfigurator.
foodmarket.Api.Infrastructure.Security.OpenIddictKeyConfigurator
.ConfigureSigningAndEncryption(opts, builder.Configuration, builder.Environment);
opts.UseAspNetCore()
.EnableTokenEndpointPassthrough()
.DisableTransportSecurityRequirement();
opts.SetAccessTokenLifetime(TimeSpan.Parse(
builder.Configuration["OpenIddict:AccessTokenLifetime"] ?? "01:00:00"));
opts.SetRefreshTokenLifetime(TimeSpan.Parse(
builder.Configuration["OpenIddict:RefreshTokenLifetime"] ?? "30.00:00:00"));
// Rolling refresh включён по умолчанию: каждый refresh гасит старый
// токен (Redeemed) и выдаёт новый. Но по умолчанию OpenIddict даёт
// 30-секундный reuse-leeway — окно, в котором погашенный refresh ещё
// принимается (для гонок/ретраев). Для розничной админки это дыра:
// утёкший refresh остаётся рабочим 30с после ротации. Обнуляем окно —
// ротация инвалидирует старый refresh немедленно.
opts.SetRefreshTokenReuseLeeway(TimeSpan.Zero);
// Явный Issuer = публичный URL админки. Без него OpenIddict
// вычисляет issuer из текущего HTTP-запроса, что ломается за
// nginx-прокси если Host/X-Forwarded-Proto не доходят. Берём
// из конфигурации (OpenIddict:Issuer / docker compose env).
if (builder.Configuration["OpenIddict:Issuer"] is { Length: > 0 } issuer)
{
opts.SetIssuer(new Uri(issuer));
}
})
.AddValidation(opts =>
{
opts.UseLocalServer();
opts.UseAspNetCore();
});
// Force OpenIddict validation to handle ALL [Authorize] challenges — otherwise AddIdentity's
// cookie scheme takes over and 401 becomes a 302 redirect to /Account/Login for API calls.
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});
builder.Services.AddAuthorization(opts =>
{
// Check the "role" claim explicitly — robust against role-claim-type mismatches between
// OpenIddict validation identity and the default ClaimTypes.Role uri.
opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx =>
ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin"))));
});
// Permission-based авторизация: [RequiresPermission("...")] → policy perm:* →
// PermissionRequirement → проверка флага RolePermissions роли сотрудника.
builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider,
foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationPolicyProvider>();
builder.Services.AddScoped<Microsoft.AspNetCore.Authorization.IAuthorizationHandler,
foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationHandler>();
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
// Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP).
// Лимиты конфигурируемы через RateLimiting:* (PerMinute/PerHour/Enabled).
builder.Services.AddAuthRateLimiting(builder.Configuration);
// Health-пробы: liveness (процесс жив) и readiness (БД + миграции применены).
// Readiness-чек помечен тегом "ready", чтобы /health/live его не запускал.
builder.Services.AddHealthChecks()
.AddCheck<foodmarket.Api.Infrastructure.Health.DatabaseReadyHealthCheck>(
"database", tags: ["ready"]);
// Email-отправка через MailKit. Singleton — внутри открывает scope для
// свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается
// на каждой отправке без рестарта приложения.
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
foodmarket.Infrastructure.Email.MailKitEmailSender>();
// EmailTemplates загружает embedded HTML и подставляет {{key}} —
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
builder.Services.AddDataProtection();
// MediatR (TD-1 partial CQRS): сканирует food-market.application для
// IRequest/IRequestHandler. Образцы: CreateSupplyCommand,
// PostRetailSaleCommand, GetSalesReportQuery. Контроллеры пока не
// переписаны под mediator — это паттерн-показ, не полный рефакторинг.
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(
typeof(foodmarket.Application.Inventory.IStockService).Assembly));
// Заглушки для абстракций CQRS-handler'ов: handler'ы пока не подключены
// к контроллерам (поэтапная миграция). Реальная имплементация появится
// когда контроллер начнёт отправлять команду через IMediator. Сейчас
// нужны только чтобы DI-validation на старте не падала.
builder.Services.AddTransient<foodmarket.Application.Purchases.Commands.ISupplyWriter,
foodmarket.Api.Infrastructure.Cqrs.UnusedSupplyWriter>();
builder.Services.AddTransient<foodmarket.Application.Sales.Commands.IRetailSalePoster,
foodmarket.Api.Infrastructure.Cqrs.UnusedRetailSalePoster>();
// FluentValidation: автоматическая регистрация валидаторов из сборки api.
// ValidationFilter гоняет валидаторы на каждом контроллер-action перед
// вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807).
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
builder.Services.AddControllers(o =>
{
// Глобальный action filter — пишет audit-log при успешных мутациях
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
o.Filters.AddService<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(opts =>
{
opts.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
{
Title = "food-market API",
Version = "v1",
Description = "Multi-tenant POS/inventory backend. Все запросы " +
"ограничены организацией текущего JWT (claim `org_id`).",
});
// Bearer JWT через OpenIddict. Swagger UI «Authorize» подставит в Authorization.
var bearer = new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{
Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
Description = "Access token, полученный через POST /connect/token.",
};
opts.AddSecurityDefinition("Bearer", bearer);
opts.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement
{
[new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{
Reference = new Microsoft.OpenApi.Models.OpenApiReference
{
Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
Id = "Bearer",
},
}] = Array.Empty<string>(),
});
// Стабильные operationId для генерации TS-клиентов:
// <Controller>_<Verb><Action>. Verb включён чтобы избежать коллизии
// когда ASP.NET стрипает Async-суффикс и два метода (WipeAll, WipeAllAsync)
// получают одинаковое имя action → одинаковый operationId → duplicate.
// Минимальные API (/health, /metrics, /connect/*) не имеют ключей
// controller/action в RouteValues — для них фоллбэк на RelativePath.
opts.CustomOperationIds(api =>
{
var rv = api.ActionDescriptor.RouteValues;
rv.TryGetValue("controller", out var ctrl);
rv.TryGetValue("action", out var action);
var verb = api.HttpMethod is { Length: > 0 } m ? char.ToUpper(m[0]) + m[1..].ToLowerInvariant() : "";
if (string.IsNullOrEmpty(ctrl) || string.IsNullOrEmpty(action))
return $"{verb}_{api.RelativePath?.Replace('/', '_').Replace('{', '_').Replace('}', '_')}";
return $"{ctrl}_{verb}{action}";
});
// У нас есть одноимённые nested record'ы в разных контроллерах
// (например, StockRow в StockController и StockReportController, или
// EmployeeInput в Organizations.EmployeesController и SuperAdmin).
// Включаем имя контроллера в schemaId через FullName-suffix чтобы не
// словить duplicate schemaId — Swashbuckle падает на этом по умолчанию.
// FullName может быть null для generic-параметров — fallback на Name.
opts.CustomSchemaIds(t => (t.FullName ?? t.Name)
.Replace("foodmarket.Api.Controllers.", "")
.Replace("foodmarket.Application.", "")
.Replace("foodmarket.Domain.", "")
.Replace("+", "_")
.Replace(".", "_"));
});
// MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge.
// BaseAddress берётся из конфигурации (MoySklad:BaseUrl) с дефолтом на боевой
// api.moysklad.ru — так интеграцию можно навести на mock-сервер в e2e/интеграционных
// тестах, не трогая прод. Трейлинг-слэш обязателен (RFC 3986 §5.3, см. MoySkladClient).
builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>(http =>
{
var baseUrl = builder.Configuration["MoySklad:BaseUrl"];
if (!string.IsNullOrWhiteSpace(baseUrl))
http.BaseAddress = new Uri(baseUrl);
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
});
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
// Inventory
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
// Hangfire — фоновые джобы и UI-дашборд. Хранилище — наш Postgres
// (тот же ConnectionString:Default). Запускаем фактический сервер только
// когда приложение действительно работает (не в тестах): чтобы тестовые
// прогоны не плодили tables/jobs в одноразовом контейнере. Регистрация
// recurring jobs — после Build() в IHostedService HangfireJobsConfigurator.
var enableHangfireServer = builder.Configuration.GetValue("Hangfire:Enabled", true);
builder.Services.AddHangfire(cfg => cfg
.SetDataCompatibilityLevel(Hangfire.CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UsePostgreSqlStorage(opts => opts.UseNpgsqlConnection(
builder.Configuration.GetConnectionString("Default"))));
if (enableHangfireServer)
{
builder.Services.AddHangfireServer(opts =>
{
opts.WorkerCount = 2;
opts.Queues = new[] { "default" };
});
builder.Services.AddHostedService<foodmarket.Api.Background.HangfireJobsConfigurator>();
}
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
// Telegram-бот владельца. Token + username берём из конфига; если token
// пустой — клиент тихо отключён, RunAsync джоба возвращается сразу. UI
// переходит в режим «бот не настроен» — кнопка «Привязать Telegram»
// показывает подсказку об env-переменной.
builder.Services.Configure<foodmarket.Api.Integrations.Telegram.TelegramOptions>(
builder.Configuration.GetSection("Telegram"));
builder.Services.AddHttpClient<foodmarket.Api.Integrations.Telegram.ITelegramBotClient,
foodmarket.Api.Integrations.Telegram.TelegramBotClient>(c =>
{
c.Timeout = TimeSpan.FromSeconds(15);
});
builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>();
// Object storage (картинки товаров, CSV-импорт). Type=Local|Minio,
// fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs.
builder.Services.AddObjectStorage(builder.Configuration);
// SignalR + per-org notification publisher. Hub смонтирован ниже на
// /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase
// имена полей в DTO (см. NotificationsPublisher payload-records).
builder.Services.AddSignalR();
builder.Services.AddSingleton<foodmarket.Api.Realtime.INotificationsPublisher,
foodmarket.Api.Realtime.NotificationsPublisher>();
builder.Services.AddHostedService<OpenIddictClientSeeder>();
builder.Services.AddHostedService<SystemReferenceSeeder>();
builder.Services.AddHostedService<DevDataSeeder>();
// Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo).
builder.Services.AddScoped<foodmarket.Api.Seed.DemoTenantSeeder>();
// Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1).
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
// DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
// Keep the file as reference for anyone starting without MoySklad access —
// just re-register here to turn demo data back on.
// builder.Services.AddHostedService<DemoCatalogSeeder>();
var app = builder.Build();
app.UseSerilogRequestLogging();
app.UseCors(CorsPolicy);
// Prometheus HTTP-метрики (http_requests_received_total, http_request_duration_seconds).
// Включаем сразу после CORS и до аутентификации, чтобы видеть и 401/403
// (это сигнал об атаке/неверной конфигурации). prometheus-net уже зашивает
// reserved-лейблы code/method/controller/action из route template'а
// дополнительной кастомизации не нужно, и она запрещена (см. ValidateMappings).
app.UseHttpMetrics();
// До аутентификации: лимитируем перебор пароля ещё на входе, не доводя
// до проверки credential'ов в БД. Перед лимитером пик form-body /connect/token,
// чтобы per-username бакет имел ключ — без этого мы можем считать только по IP,
// и любая NAT/CI-машина роняется первой.
app.UseAuthFormUsernameKey();
app.UseRateLimiter();
// SignalR-clients не могут слать кастомные хедеры через WebSocket, поэтому
// токен приходит как `?access_token=...`. До UseAuthentication перекладываем
// его в Authorization-хедер, чтобы OpenIddict валидатор отработал штатно.
app.Use(async (ctx, next) =>
{
var path = ctx.Request.Path;
if (path.StartsWithSegments("/hubs")
&& !ctx.Request.Headers.ContainsKey("Authorization")
&& ctx.Request.Query.TryGetValue("access_token", out var tok)
&& !string.IsNullOrEmpty(tok))
{
ctx.Request.Headers["Authorization"] = $"Bearer {tok}";
}
await next();
});
app.UseAuthentication();
app.UseAuthorization();
// После аутентификации, до контроллеров: вытягиваем OrgId/UserId из ClaimsPrincipal
// и кладём в Serilog LogContext вместе с CorrelationId — каждая ILogger.Log
// в пайплайне автоматически получит эти лейблы.
app.UseMiddleware<foodmarket.Api.Infrastructure.Observability.LogEnrichmentMiddleware>();
// SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но
// только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*.
app.UseMiddleware<foodmarket.Api.Infrastructure.Tenancy.ReadonlyOverrideMiddleware>();
// Статика товарных изображений: физически /app/uploads (volume в compose),
// публичный URL /uploads/... — раздаются public, без auth.
var uploadsDir = System.IO.Path.Combine(app.Environment.ContentRootPath, "uploads");
System.IO.Directory.CreateDirectory(uploadsDir);
app.UseStaticFiles(new Microsoft.AspNetCore.Builder.StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsDir),
RequestPath = "/uploads",
});
// Swagger/OpenAPI: всегда в Development, в Production — только при IncludeSwagger=true
// (используется на stage для дебага без отдельного билда). На prod admin.food-market.kz
// флаг не выставляем — sensitive endpoint enumeration.
var includeSwagger = app.Environment.IsDevelopment()
|| builder.Configuration.GetValue<bool>("IncludeSwagger");
if (includeSwagger)
{
app.UseSwagger();
app.UseSwaggerUI(opts =>
{
opts.DocumentTitle = "food-market API";
opts.RoutePrefix = "swagger";
});
}
app.MapControllers();
// SignalR hub для live-уведомлений per-org. Path: /hubs/notifications.
// JWT доставляется либо через Authorization-заголовок (наш AuthClient
// делает это автоматически), либо через query `?access_token=...`
// (фронт react useNotificationsHub использует именно его, потому что
// браузерные WebSocket не поддерживают custom headers).
app.MapHub<foodmarket.Api.Realtime.NotificationsHub>("/hubs/notifications");
// /metrics — текстовый Prometheus exposition format. Скрейпится prometheus-сервером
// (rate=15s типично). Доступ без авторизации — стандартная практика;
// на prod ограничиваем nginx-уровнем (allow 10.0.0.0/8, deny all) или basic-auth.
app.MapMetrics("/metrics");
// Hangfire Dashboard на /hangfire — только SuperAdmin. Auth-фильтр
// проверяет System.Security.Claims.ClaimsPrincipal (стандартный
// OpenIddict-токен в Authorization-заголовке). Не вешаем UseHangfireServer —
// он уже стартует через AddHangfireServer выше.
if (enableHangfireServer)
{
app.UseHangfireDashboard("/hangfire", new Hangfire.DashboardOptions
{
Authorization = new[] { new foodmarket.Api.Background.SuperAdminHangfireFilter() },
// Не показываем команды Delete/Requeue по умолчанию из UI чтобы случайные клики
// не разрушили scheduled — все джобы декларативные, перерегистрация делает их
// идемпотентной.
IgnoreAntiforgeryToken = false,
});
}
// Liveness: процесс отвечает — без обращения к зависимостям (Predicate=false
// => ни один чек не запускается). Используется для рестарта зависшего контейнера.
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = _ => false,
ResponseWriter = WriteHealthResponse,
});
// Readiness: запускает чеки с тегом "ready" (БД + миграции). 503 если не готово.
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = WriteHealthResponse,
});
// Backward-compat: /health = liveness (Dockerfile/nginx исторически бьют сюда).
app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow }));
app.MapGet("/api/_debug/whoami", (HttpContext ctx) =>
{
var identity = ctx.User.Identity as System.Security.Claims.ClaimsIdentity;
return Results.Ok(new
{
isAuthenticated = ctx.User.Identity?.IsAuthenticated,
authType = ctx.User.Identity?.AuthenticationType,
nameClaimType = identity?.NameClaimType,
roleClaimType = identity?.RoleClaimType,
isInRoleAdmin = ctx.User.IsInRole("Admin"),
hasAdminRoleClaim = ctx.User.HasClaim(c => c.Type == Claims.Role && c.Value == "Admin"),
claims = ctx.User.Claims.Select(c => new { c.Type, c.Value }),
});
}).RequireAuthorization();
app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) =>
{
var user = ctx.User;
var roles = user.FindAll(Claims.Role).Select(c => c.Value).ToList();
var orgIdRaw = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value;
var subRaw = user.FindFirst(Claims.Subject)?.Value;
// Дополнительные сигналы, чтобы фронт точно знал в каком состоянии юзер
// находится: orphan AppUser без живой org или без активного Employee
// получает /no-organization fallback вместо белого экрана на /dashboard.
bool hasLiveOrg = false;
bool hasActiveEmployee = false;
if (Guid.TryParse(orgIdRaw, out var orgId))
{
hasLiveOrg = await db.Organizations.IgnoreQueryFilters()
.AnyAsync(o => o.Id == orgId && !o.IsArchived);
if (Guid.TryParse(subRaw, out var sub))
{
hasActiveEmployee = await db.Employees.IgnoreQueryFilters()
.AnyAsync(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive);
}
}
return Results.Ok(new
{
sub = subRaw,
name = user.Identity?.Name,
email = user.FindFirst(Claims.Email)?.Value,
roles,
orgId = orgIdRaw,
hasLiveOrg,
hasActiveEmployee,
});
}).RequireAuthorization();
// Apply migrations on every startup (idempotent). Without this, fresh
// stage/prod deploys land on an empty DB and OpenIddict seeders fail.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
}
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
// JSON-ответ health-проб: общий статус + по каждому чеку. text/plain по умолчанию
// неудобен для мониторинга; отдаём структурировано.
static Task WriteHealthResponse(HttpContext context,
Microsoft.Extensions.Diagnostics.HealthChecks.HealthReport report)
{
context.Response.ContentType = "application/json";
return context.Response.WriteAsJsonAsync(new
{
status = report.Status.ToString(),
totalDurationMs = report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description,
}),
});
}
// Делает сгенерированный из top-level statements класс Program видимым для
// интеграционных тестов (WebApplicationFactory&lt;Program&gt;).
public partial class Program;