food-market/src/food-market.api/Program.cs
nns e78f14bc6b
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
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
test(pos): инструменты замера старта — bulk-каталог + bench
- POST /api/admin/seed-demo?bulk=N — массовое наполнение каталога (до 60k товаров)
  батчами для нагрузочных замеров (идемпотентно по маркеру BULK-).
- POS --bench <url> <user> <pass>: мерит первичную загрузку (сеть/запись отдельно),
  тёплый старт (открытие базы + поиск), дельта-синк. С gzip-декомпрессией.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 04:59:11 +05:00

830 lines
50 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>();
// Sprint 30: «быстрый вход» на тестовом стенде. Dev-only флаг, выключен
// по умолчанию, с safety-override по Issuer (см. QuickLoginOptions).
builder.Services.Configure<foodmarket.Api.Infrastructure.QuickLoginOptions>(
builder.Configuration.GetSection("QuickLogin"));
// Sprint 20 / TD-3: Mapster config — singleton TypeAdapterConfig.
// Используется в контроллерах через ProjectToType<TDto>(MapsterConfig.Config)
// для SQL-friendly проекций. IMapper в DI не регистрируем, потому что
// ProjectToType<T>() работает напрямую с config'ом — это эквивалент
// ручного `Select(...)` без runtime-reflection.
builder.Services.AddSingleton(foodmarket.Application.Mapping.MapsterConfig.Config);
// 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>();
// Sprint 14: явная конфигурация Npgsql connection pool.
// Дефолты Npgsql: Max=100, Min=0, IdleLifetime=300 — формально нормально,
// но Min=0 в моменты низкого трафика убивает все коннекшены, и первый
// запрос после простоя платит handshake + auth (50-100ms). Подняли
// Min до 10 — пул всегда греется. Max=100 оставили (PG default
// max_connections=100, превышать без тюна сервера нельзя).
// Переопределяется через ConnectionStrings:Default (если в строке
// уже есть Maximum/Minimum Pool Size — наш код не перетирает).
static string ApplyDefaultPoolConfig(string? raw)
{
if (string.IsNullOrEmpty(raw)) return raw ?? "";
var lower = raw.ToLowerInvariant();
var b = new System.Text.StringBuilder(raw);
if (!raw.EndsWith(";")) b.Append(';');
if (!lower.Contains("maximum pool size")) b.Append("Maximum Pool Size=100;");
if (!lower.Contains("minimum pool size")) b.Append("Minimum Pool Size=10;");
if (!lower.Contains("connection idle lifetime")) b.Append("Connection Idle Lifetime=300;");
// Auto-prepare часто используемых запросов — заметно ускоряет EF Core
// на стабильном rotational query mix'е. Threshold=5 = после 5 calls
// одного query шаблона PG получает PREPARE, дальнейшие round-trip'ы
// идут как EXECUTE prepared (без re-parse/re-plan).
if (!lower.Contains("max auto prepare")) b.Append("Max Auto Prepare=20;");
if (!lower.Contains("auto prepare min usages")) b.Append("Auto Prepare Min Usages=5;");
return b.ToString();
}
var poolTunedConnString = ApplyDefaultPoolConfig(builder.Configuration.GetConnectionString("Default"));
builder.Services.AddDbContext<AppDbContext>((sp, opts) =>
{
opts.UseNpgsql(poolTunedConnString,
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();
// Быстрый вход кассира по PIN на зарегистрированном устройстве
// (grant_type=pin, параметры retail_point_id + pin). См. AuthorizationController.
opts.AllowCustomFlow("pin");
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.
var authBuilder = builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});
// Sprint 20: SSO scaffolding. Внешние providers подключаются только когда
// в конфиге задан ClientId — иначе их не регистрируем (контроллер вернёт
// 503 на /api/auth/external/{provider}). Cookie-схема нужна для временного
// хранения OAuth-стейта между challenge и callback'ом.
var googleId = builder.Configuration["Authentication:Google:ClientId"];
var googleSecret = builder.Configuration["Authentication:Google:ClientSecret"];
var msId = builder.Configuration["Authentication:Microsoft:ClientId"];
var msSecret = builder.Configuration["Authentication:Microsoft:ClientSecret"];
var anySso = !string.IsNullOrEmpty(googleId) || !string.IsNullOrEmpty(msId);
if (anySso)
{
authBuilder.AddCookie("ExternalSignIn", o =>
{
o.Cookie.Name = "fm.external";
o.Cookie.HttpOnly = true;
o.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax;
o.ExpireTimeSpan = TimeSpan.FromMinutes(10);
});
if (!string.IsNullOrEmpty(googleId) && !string.IsNullOrEmpty(googleSecret))
{
authBuilder.AddGoogle("Google", o =>
{
o.ClientId = googleId;
o.ClientSecret = googleSecret;
o.SignInScheme = "ExternalSignIn";
o.CallbackPath = "/signin-google";
o.SaveTokens = false;
});
}
if (!string.IsNullOrEmpty(msId) && !string.IsNullOrEmpty(msSecret))
{
authBuilder.AddMicrosoftAccount("Microsoft", o =>
{
o.ClientId = msId;
o.ClientSecret = msSecret;
o.SignInScheme = "ExternalSignIn";
o.CallbackPath = "/signin-microsoft";
o.SaveTokens = false;
});
}
}
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>();
// Sprint 13: централизованный логгер sensitive-операций (смена пароля,
// 2FA, выдача роли, смена владельца, revoke-all sessions). Пишет в
// org_audit_log + Serilog. См. SensitiveOpsAudit.
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit>();
// Sprint 14: генерация thumb/medium WebP-вариантов при загрузке картинки товара.
builder.Services.AddScoped<foodmarket.Api.Storage.ImageVariantService>();
// 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>();
// ─ Sprint 11: ОФД (фискализация чеков в РК) ────────────────────────
// Все провайдеры регистрируются одновременно — фабрика выбирает по
// FiscalProvider в настройках организации. Default — None: чеки
// проводятся без фискализации, поведение «как до Sprint 11».
//
// Mock = Transient (без HTTP); остальные через AddHttpClient — это
// даёт автоматический pooled HttpMessageHandler + интеграцию с
// IHttpClientFactory (вынос в Polly / переопределение handler'а
// в тестах через .AddHttpMessageHandler).
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider,
foodmarket.Infrastructure.Fiscal.MockFiscalProvider>();
builder.Services.AddHttpClient<foodmarket.Infrastructure.Fiscal.WebkassaProvider>();
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider>(sp =>
sp.GetRequiredService<foodmarket.Infrastructure.Fiscal.WebkassaProvider>());
builder.Services.AddHttpClient<foodmarket.Infrastructure.Fiscal.Kassa24Provider>();
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider>(sp =>
sp.GetRequiredService<foodmarket.Infrastructure.Fiscal.Kassa24Provider>());
builder.Services.AddHttpClient<foodmarket.Infrastructure.Fiscal.OfdSoloProvider>();
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider>(sp =>
sp.GetRequiredService<foodmarket.Infrastructure.Fiscal.OfdSoloProvider>());
builder.Services.AddScoped<foodmarket.Application.Common.Fiscal.IFiscalProviderFactory,
foodmarket.Infrastructure.Fiscal.FiscalProviderFactory>();
// EmailTemplates загружает embedded HTML и подставляет {{key}} —
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
builder.Services.AddDataProtection();
// Sprint 2026-10-05 (handoff §9 порт из sklad): для фото-приёмок клиент
// шлёт пачку снимков (до 60 МБ) — ответ API маленький; но мобильный
// «mobile-catalog» на 31 000 позиций — это ~2 МБ JSON, его стоит сжимать.
// Включаем gzip для application/json; «br» умеет не каждый прокси, а
// большой выгоды над gzip на коротких текстах не даёт.
builder.Services.AddResponseCompression(opts =>
{
opts.EnableForHttps = true;
opts.Providers.Add<Microsoft.AspNetCore.ResponseCompression.GzipCompressionProvider>();
opts.MimeTypes = Microsoft.AspNetCore.ResponseCompression.ResponseCompressionDefaults.MimeTypes
.Concat(new[] { "application/json; charset=utf-8", "text/csv" });
});
// 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>();
// Port sklad 06.10 (handoff §1 / §10). Nullable=enable + ASP.NET MVC
// по умолчанию добавляет неявный [Required] на каждое non-nullable
// string в input-модели. Любое новое необязательное текстовое поле
// (InvoiceLine.Note, BarcodeSource и т.п.) — и клиент получает 400
// «The X field is required» на пустой строке. Отключаем: FluentValidation
// поверх (ValidationFilter + Validators.cs) явно гоняет NotEmpty там,
// где поле реально обязательно; остальные — nullable или имеют default.
o.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
})
.ConfigureApiBehaviorOptions(o =>
{
// ValidationProblemDetails совместим с humanizeError на фронте
// (data.errors[*][0] → data.title → data.detail). Параллельно пишем
// Warning с путём и собранными ошибками — так бэкенд всегда видит
// «за какое поле отвалилось» без детализации в теле ответа.
o.InvalidModelStateResponseFactory = ctx =>
{
var problems = ctx.ModelState
.Where(kv => kv.Value?.Errors.Count > 0)
.Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value!.Errors.Select(e => e.ErrorMessage))}")
.ToList();
ctx.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("ModelValidation")
.LogWarning("[Проверка] {Path}: {Problems}",
ctx.HttpContext.Request.Path, string.Join("; ", problems));
var pd = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(ctx.ModelState)
{
Status = StatusCodes.Status400BadRequest,
Title = "Сервер не принял данные",
Detail = string.Join("; ", problems.Take(3))
+ (problems.Count > 3 ? $" и ещё {problems.Count - 3}" : ""),
};
return new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(pd)
{
ContentTypes = { "application/problem+json" },
};
};
});
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.INegativeStockAlertService, foodmarket.Infrastructure.Inventory.NegativeStockAlertService>();
builder.Services.AddScoped<foodmarket.Application.Notifications.IPushSender, foodmarket.Infrastructure.Notifications.StubPushSender>();
builder.Services.AddScoped<foodmarket.Application.Notifications.IWebPushSender, foodmarket.Infrastructure.Notifications.WebPushSender>();
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
// Sprint 33: модуль «Деньги»
builder.Services.AddScoped<foodmarket.Application.Money.IMoneyService,
foodmarket.Infrastructure.Money.MoneyService>();
// 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>();
// Sprint 14: timing-фильтр для всех job'ов — пишет длительность каждого
// выполнения в Serilog. Долгие (>30с) логируются как Warning.
builder.Services.AddSingleton<foodmarket.Api.Background.JobTimingFilter>();
builder.Services.AddHostedService<foodmarket.Api.Background.HangfireGlobalFilterRegistrar>();
}
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
// Sprint 20: DB VACUUM ANALYZE + disk monitoring.
builder.Services.AddScoped<foodmarket.Api.Background.DatabaseMaintenanceJobs>();
builder.Services.AddScoped<foodmarket.Api.Background.DiskMonitoringJob>();
// Sprint 22: GDPR org export + DB schema docs.
builder.Services.AddScoped<foodmarket.Api.Background.OrgExportJob>();
builder.Services.AddScoped<foodmarket.Api.Background.DbSchemaDocsJob>();
// Sprint 24: API endpoint reference docs (weekly).
builder.Services.AddScoped<foodmarket.Api.Background.ApiReferenceDocsJob>();
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>();
builder.Services.AddScoped<foodmarket.Api.Background.NegativeStockNotificationJob>();
// 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>();
// Sprint 31: приёмка по фото накладной. Extractor — Claude CLI headless
// (без API-ключей, нагрузка списывается с подписки). InvoiceWorker в фоне
// обрабатывает очередь pending → needs_review.
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Invoices.IInvoiceExtractor,
foodmarket.Api.Infrastructure.Invoices.ClaudeCliInvoiceExtractor>();
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Invoices.InvoiceProcessor>();
builder.Services.AddHostedService<foodmarket.Api.Infrastructure.Invoices.InvoiceWorker>();
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>();
// Sprint 30: демо-trio «Демо-магазина» для quick-login — только когда флаг
// эффективно включён. Внутри сам чекает EffectivelyEnabled().
builder.Services.AddHostedService<foodmarket.Api.Seed.QuickLoginDemoSeeder>();
// Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1).
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
// Витринный сидер для скриншотов сайта (POST /api/admin/seed-demo?showcase=1).
builder.Services.AddScoped<foodmarket.Api.Seed.ShowcaseSeeder>();
builder.Services.AddScoped<foodmarket.Api.Seed.BulkCatalogSeeder>();
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>();
// Sprint 13: security-заголовки (CSP, X-Frame-Options и т.д.). Опции
// переопределяются секцией Security в конфиге; по дефолту — самодостаточный
// SPA + API на одном origin, см. SecurityHeadersOptions.DefaultCsp.
builder.Services.AddSingleton(sp =>
{
var opts = new foodmarket.Api.Infrastructure.Security.SecurityHeadersOptions();
var section = builder.Configuration.GetSection("Security");
var csp = section["ContentSecurityPolicy"];
if (!string.IsNullOrWhiteSpace(csp)) opts.ContentSecurityPolicy = csp;
return opts;
});
// HSTS-параметры для продакшна. 365 дней + includeSubDomains — стандарт.
// preload включаем: домен admin.food-market.kz можно подать в
// hstspreload.org когда созреем, без preload директивы — нельзя.
builder.Services.AddHsts(opts =>
{
opts.MaxAge = TimeSpan.FromDays(365);
opts.IncludeSubDomains = true;
opts.Preload = true;
});
var app = builder.Build();
// Sprint 30: один раз на старте проверяем решение по quick-login
// (Enabled+Issuer). Пишет Warning и в прод-env, и в стенде, чтобы в логах
// всегда было видно, в каком режиме стартанули.
{
var ql = app.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<
foodmarket.Api.Infrastructure.QuickLoginOptions>>().Value;
var issuer = builder.Configuration["OpenIddict:Issuer"];
ql.LogSafetyDecision(app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("QuickLogin"), issuer);
}
// HSTS — только когда мы за HTTPS (stage/prod через nginx). В Development
// обычно работаем по http://localhost:5081, и Strict-Transport-Security
// прибил бы локальный браузер на год. По дефолту 365 дней + preload.
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
// Security-заголовки на КАЖДЫЙ ответ — раньше всех остальных middleware'ов,
// чтобы они применились даже на 429/403 от rate-limiter'а.
app.UseMiddleware<foodmarket.Api.Infrastructure.Security.SecurityHeadersMiddleware>();
// Sprint 23 / bug-003: глобальный перехватчик PostgreSQL serialization
// failure'ов (SqlState=40001) — мапит на 409 Conflict + retryable:true.
// Без него параллельные posting'и под Serializable отвечают 500.
app.UseMiddleware<foodmarket.Api.Infrastructure.SerializationConflictMiddleware>();
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.UseResponseCompression();
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;
// Sprint 29 (simple-mode): флаг расширенного режима организации.
// false по умолчанию → UI показывает только «простое» меню.
// Включает только SuperAdmin через /api/super-admin/organizations/{id}/advanced-mode.
bool advancedMode = false;
// Sprint (mobile-api): эффективные права текущего пользователя —
// единый источник для мобильного и веба (скрывают одно и то же).
foodmarket.Domain.Organizations.RolePermissions? rolePerms = null;
List<Guid> retailPointIds = new();
var isSuperAdmin = roles.Contains(HttpContextTenantContext.SuperAdminRole);
var isAdmin = roles.Contains("Admin");
if (Guid.TryParse(orgIdRaw, out var orgId))
{
var org = await db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId)
.Select(o => new { o.IsArchived, o.IsAdvancedMode })
.FirstOrDefaultAsync();
if (org is not null)
{
hasLiveOrg = !org.IsArchived;
advancedMode = org.IsAdvancedMode;
}
if (Guid.TryParse(subRaw, out var sub))
{
var emp = await db.Employees.IgnoreQueryFilters()
.Where(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive && !e.IsDeleted)
.Select(e => new { e.Id, e.Role })
.FirstOrDefaultAsync();
hasActiveEmployee = emp is not null;
rolePerms = emp?.Role?.Permissions;
// Точки продаж, к которым привязан сотрудник (для роли Кассир —
// касса пускает только на «свою» точку). Админ не привязан —
// пустой список + право RetailPointsManage → доступ ко всем.
if (emp is not null)
retailPointIds = await db.Set<foodmarket.Domain.Organizations.EmployeeRetailPointAssignment>()
.IgnoreQueryFilters()
.Where(a => a.EmployeeId == emp.Id && a.OrganizationId == orgId)
.Select(a => a.RetailPointId)
.ToListAsync();
}
}
var permissions = foodmarket.Domain.Organizations.RolePermissions.Effective(
rolePerms, isSuperAdmin, isAdmin, advancedMode);
return Results.Ok(new
{
sub = subRaw,
name = user.Identity?.Name,
email = user.FindFirst(Claims.Email)?.Value,
roles,
orgId = orgIdRaw,
hasLiveOrg,
hasActiveEmployee,
advancedMode,
// Эффективные права (имя→bool) с учётом роли и простого/расширенного
// режима. Клиенты (моб./веб) гейтят UI по этим флагам.
permissions,
// Точки продаж сотрудника (касса пускает кассира только на свою).
retailPointIds,
});
}).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;