From a74fa114d8c8a8db8084c308f163650729a075ce Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 28 May 2026 10:07:14 +0500 Subject: [PATCH] =?UTF-8?q?feat(hangfire):=20dashboard=20+=20scheduled=20c?= =?UTF-8?q?leanup=20=D0=B4=D0=B6=D0=BE=D0=B1=D1=8B=20(P1-16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hangfire.PostgreSql storage (тот же ConnectionString:Default). Сервер стартует только когда Hangfire:Enabled (true по умолчанию) — в интеграционных тестах выключаем через env Hangfire__Enabled=false, чтобы тесты не плодили служебные таблицы в одноразовом контейнере. Dashboard на /hangfire с авторизационным фильтром SuperAdminHangfireFilter — требует роли SuperAdmin (стандартный OpenIddict-токен валидируется аутентификационным middleware'ом перед этим). Recurring jobs (HangfireJobsConfigurator): • prune-stock-movements — ежедневно 03:30 UTC, удаляет StockMovement старше 730 дней (Hangfire:Retention:StockMovementDays). За 30 минут до бэкапа, чтобы pg_dump не цеплял временные блокировки. • prune-audit-log — ежедневно 03:45 UTC, удаляет super_admin_audit_log старше 90 дней (Hangfire:Retention:AuditLogDays). Логика очистки в HousekeepingJobs (scoped, использует AppDbContext с IgnoreQueryFilters — это межтенантная задача). Тесты: 1 unit (PruneStockMovements удаляет только старые), 1 интеграционный (dashboard не отвечает без Hangfire-сервера). Полный прогон: 24 unit + 32 integration = 56 зелёных. Co-Authored-By: Claude Opus 4.7 --- .../Background/HangfireJobsConfigurator.cs | 46 +++++++++++++ .../Background/HousekeepingJobs.cs | 64 ++++++++++++++++++ .../Background/SuperAdminHangfireFilter.cs | 22 +++++++ src/food-market.api/Program.cs | 41 ++++++++++++ src/food-market.api/food-market.api.csproj | 1 + .../HangfireDashboardTests.cs | 26 ++++++++ .../Support/ApiFactory.cs | 4 ++ .../HousekeepingJobsTests.cs | 65 +++++++++++++++++++ .../food-market.UnitTests.csproj | 1 + 9 files changed, 270 insertions(+) create mode 100644 src/food-market.api/Background/HangfireJobsConfigurator.cs create mode 100644 src/food-market.api/Background/HousekeepingJobs.cs create mode 100644 src/food-market.api/Background/SuperAdminHangfireFilter.cs create mode 100644 tests/food-market.IntegrationTests/HangfireDashboardTests.cs create mode 100644 tests/food-market.UnitTests/HousekeepingJobsTests.cs diff --git a/src/food-market.api/Background/HangfireJobsConfigurator.cs b/src/food-market.api/Background/HangfireJobsConfigurator.cs new file mode 100644 index 0000000..4433e07 --- /dev/null +++ b/src/food-market.api/Background/HangfireJobsConfigurator.cs @@ -0,0 +1,46 @@ +using Hangfire; + +namespace foodmarket.Api.Background; + +/// Регистрирует recurring jobs Hangfire при старте приложения. +/// Идемпотентно: RecurringJob.AddOrUpdate перетирает существующее +/// определение, так что миграции job'ов проходят без ручной очистки таблицы +/// hangfire.recurring_jobs. +/// +/// Cron-выражения берутся из конфига (Hangfire:Cron:*) с дефолтом +/// «каждый день в 03:30 UTC» для cleanup-джобов — за 30 минут до бэкапа, +/// чтобы бэкап не цеплял временные блокировки PruneStockMovements. +public class HangfireJobsConfigurator : IHostedService +{ + private readonly IRecurringJobManager _jobs; + private readonly IConfiguration _cfg; + + public HangfireJobsConfigurator(IRecurringJobManager jobs, IConfiguration cfg) + { + _jobs = jobs; + _cfg = cfg; + } + + public Task StartAsync(CancellationToken ct) + { + // Cron в Hangfire — стандартный 5-полевой (UTC по умолчанию). + var cronStock = _cfg["Hangfire:Cron:PruneStockMovements"] ?? "30 3 * * *"; + var cronAudit = _cfg["Hangfire:Cron:PruneAuditLog"] ?? "45 3 * * *"; + + _jobs.AddOrUpdate( + recurringJobId: "prune-stock-movements", + methodCall: j => j.PruneStockMovementsAsync(CancellationToken.None), + cronExpression: cronStock, + options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + + _jobs.AddOrUpdate( + recurringJobId: "prune-audit-log", + methodCall: j => j.PruneAuditLogAsync(CancellationToken.None), + cronExpression: cronAudit, + options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken ct) => Task.CompletedTask; +} diff --git a/src/food-market.api/Background/HousekeepingJobs.cs b/src/food-market.api/Background/HousekeepingJobs.cs new file mode 100644 index 0000000..bf0d414 --- /dev/null +++ b/src/food-market.api/Background/HousekeepingJobs.cs @@ -0,0 +1,64 @@ +using foodmarket.Domain.Inventory; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Background; + +/// Hangfire-джобы для регулярной чистки исторических данных: +/// StockMovement старше 2 лет (компенсирует разрастание таблицы при +/// большом обороте продаж), SuperAdminAuditLog старше 90 дней. Период +/// можно поменять через конфиг Hangfire:Retention:*. +/// +/// Реализованы как scoped-сервис, чтобы Hangfire разрешил +/// и фильтр tenant'а корректно (контекст НЕТ tenant'а — но cleanup идёт без +/// tenant-фильтра через IgnoreQueryFilters, потому что это межтенантная задача). +public class HousekeepingJobs +{ + private readonly AppDbContext _db; + private readonly IConfiguration _cfg; + private readonly ILogger _log; + + public HousekeepingJobs(AppDbContext db, IConfiguration cfg, ILogger log) + { + _db = db; + _cfg = cfg; + _log = log; + } + + /// Удаляет старше N дней (по умолчанию 730). + /// Stock-инвариант (Stock.Quantity ≡ Σ StockMovement) ломается на удалённых + /// записях, поэтому удаляем ТОЛЬКО действительно старые движения — там + /// невозможно сослаться через unpost (документы уже закрыты). + /// + /// Возвращает количество удалённых строк (для логов/мониторинга). + public async Task PruneStockMovementsAsync(CancellationToken ct = default) + { + var days = _cfg.GetValue("Hangfire:Retention:StockMovementDays", 730); + var threshold = DateTime.UtcNow.AddDays(-days); + + var deleted = await _db.StockMovements + .IgnoreQueryFilters() + .Where(m => m.OccurredAt < threshold) + .ExecuteDeleteAsync(ct); + _log.LogInformation("Hangfire/PruneStockMovements: удалено {Count} движений старше {Threshold:O}", + deleted, threshold); + return deleted; + } + + /// Удаляет super_admin_audit_log старше N дней (по умолчанию 90). + /// SuperAdmin'ские действия в каждой орге — отдельный аудит, для compliance + /// храним недолго (квартал) и зачищаем. + public async Task PruneAuditLogAsync(CancellationToken ct = default) + { + var days = _cfg.GetValue("Hangfire:Retention:AuditLogDays", 90); + var threshold = DateTime.UtcNow.AddDays(-days); + + var deleted = await _db.SuperAdminAuditLogs + .IgnoreQueryFilters() + .Where(a => a.CreatedAt < threshold) + .ExecuteDeleteAsync(ct); + _log.LogInformation("Hangfire/PruneAuditLog: удалено {Count} записей старше {Threshold:O}", + deleted, threshold); + return deleted; + } +} diff --git a/src/food-market.api/Background/SuperAdminHangfireFilter.cs b/src/food-market.api/Background/SuperAdminHangfireFilter.cs new file mode 100644 index 0000000..59b0c90 --- /dev/null +++ b/src/food-market.api/Background/SuperAdminHangfireFilter.cs @@ -0,0 +1,22 @@ +using Hangfire.Dashboard; + +namespace foodmarket.Api.Background; + +/// Авторизационный фильтр Hangfire Dashboard. Пускает только +/// аутентифицированных пользователей с ролью SuperAdmin. ClaimsPrincipal +/// берётся из HttpContext.User — то есть стандартный OpenIddict-токен +/// (Bearer) валидируется до этой проверки middleware'ом аутентификации. +/// +/// Hangfire по умолчанию пускает только loopback — мы хотим строже: +/// доступ к дашборду = доступ к фоновым джобам всех тенантов, что эквивалентно +/// SuperAdmin-консоли в Web-админке. +public class SuperAdminHangfireFilter : IDashboardAuthorizationFilter +{ + public bool Authorize(DashboardContext context) + { + var http = context.GetHttpContext(); + var user = http.User; + if (user?.Identity?.IsAuthenticated != true) return false; + return user.IsInRole("SuperAdmin"); + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 6e94253..9653fc3 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -1,4 +1,6 @@ using System.Security.Claims; +using Hangfire; +using Hangfire.PostgreSql; using foodmarket.Api.Infrastructure.RateLimiting; using foodmarket.Api.Infrastructure.Tenancy; using foodmarket.Api.Seed; @@ -178,6 +180,29 @@ // Inventory builder.Services.AddScoped(); + // 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(); + } + builder.Services.AddScoped(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -218,6 +243,22 @@ app.MapControllers(); + // 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 diff --git a/src/food-market.api/food-market.api.csproj b/src/food-market.api/food-market.api.csproj index 4407136..c92d6e0 100644 --- a/src/food-market.api/food-market.api.csproj +++ b/src/food-market.api/food-market.api.csproj @@ -21,6 +21,7 @@ + diff --git a/tests/food-market.IntegrationTests/HangfireDashboardTests.cs b/tests/food-market.IntegrationTests/HangfireDashboardTests.cs new file mode 100644 index 0000000..4f22d6a --- /dev/null +++ b/tests/food-market.IntegrationTests/HangfireDashboardTests.cs @@ -0,0 +1,26 @@ +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class HangfireDashboardTests +{ + private readonly ApiFactory _factory; + public HangfireDashboardTests(ApiFactory factory) => _factory = factory; + + /// В тестах Hangfire-сервер выключен (Hangfire__Enabled=false), и + /// dashboard-маршрут не маппится. Проверяем что /hangfire вообще не отвечает + /// 200/302 без auth — то есть гейт работает на уровне отсутствия маршрута. + /// На проде (Hangfire включён) фильтр SuperAdminHangfireFilter блокирует + /// неавторизованных пользователей. См. SuperAdminHangfireFilter. + [Fact] + public async Task Dashboard_not_exposed_when_disabled_in_tests() + { + var api = new ApiActor(_factory.CreateClient()); + using var resp = await api.Http.GetAsync("/hangfire"); + // В тестовом режиме сервер не поднят, маршрут не зарегистрирован → 404. + resp.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound); + } +} diff --git a/tests/food-market.IntegrationTests/Support/ApiFactory.cs b/tests/food-market.IntegrationTests/Support/ApiFactory.cs index 7b75192..02c95b5 100644 --- a/tests/food-market.IntegrationTests/Support/ApiFactory.cs +++ b/tests/food-market.IntegrationTests/Support/ApiFactory.cs @@ -24,6 +24,10 @@ static ApiFactory() Environment.SetEnvironmentVariable("RateLimiting__Enabled", "false"); // Тише логи в тестовом прогоне (OpenIddict сыплет Debug-событиями). Environment.SetEnvironmentVariable("Serilog__MinimumLevel__Default", "Warning"); + // Hangfire-сервер не нужен в интеграционных тестах: он бы создавал свои + // таблицы в одноразовом контейнере и удерживал коннекшен. Сам клиент + // (AddHangfire) остаётся — recurring jobs не регистрируются. + Environment.SetEnvironmentVariable("Hangfire__Enabled", "false"); } private readonly PostgreSqlContainer _db = new PostgreSqlBuilder() diff --git a/tests/food-market.UnitTests/HousekeepingJobsTests.cs b/tests/food-market.UnitTests/HousekeepingJobsTests.cs new file mode 100644 index 0000000..503857f --- /dev/null +++ b/tests/food-market.UnitTests/HousekeepingJobsTests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using foodmarket.Api.Background; +using foodmarket.Domain.Inventory; +using foodmarket.Domain.Platform; +using foodmarket.Infrastructure.Persistence; +using foodmarket.UnitTests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace foodmarket.UnitTests; + +/// Юнит-тест: HousekeepingJobs.PruneStockMovements удаляет только +/// движения старше N дней (по умолчанию 730). Stock-инвариант не проверяем — +/// это межтенантная задача очистки далёкой истории, документы за этот горизонт +/// в норме закрыты. +public class HousekeepingJobsTests +{ + private static IConfiguration CfgWith(int stockDays = 730, int auditDays = 90) => + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Hangfire:Retention:StockMovementDays"] = stockDays.ToString(), + ["Hangfire:Retention:AuditLogDays"] = auditDays.ToString(), + }).Build(); + + [Fact] + public async Task PruneStockMovements_deletes_only_old_rows() + { + using var sqlite = new SqliteDb(foreignKeys: false); + var orgId = Guid.NewGuid(); + var tenant = new FakeTenantContext { OrganizationId = orgId }; + + using (var db = sqlite.Create(tenant)) + { + db.StockMovements.AddRange( + new StockMovement + { + OrganizationId = orgId, ProductId = Guid.NewGuid(), StoreId = Guid.NewGuid(), + Quantity = 1m, Type = MovementType.Supply, DocumentType = "supply", + OccurredAt = DateTime.UtcNow.AddYears(-3), // старая + }, + new StockMovement + { + OrganizationId = orgId, ProductId = Guid.NewGuid(), StoreId = Guid.NewGuid(), + Quantity = 1m, Type = MovementType.Supply, DocumentType = "supply", + OccurredAt = DateTime.UtcNow.AddDays(-30), // свежая + }); + await db.SaveChangesAsync(); + } + + int deleted; + using (var db = sqlite.Create(tenant)) + { + var jobs = new HousekeepingJobs(db, CfgWith(stockDays: 730), NullLogger.Instance); + deleted = await jobs.PruneStockMovementsAsync(); + } + + deleted.Should().Be(1); + using (var db = sqlite.Create(tenant)) + { + (await db.StockMovements.IgnoreQueryFilters().CountAsync()).Should().Be(1); + } + } +} diff --git a/tests/food-market.UnitTests/food-market.UnitTests.csproj b/tests/food-market.UnitTests/food-market.UnitTests.csproj index a2966d7..0152f53 100644 --- a/tests/food-market.UnitTests/food-market.UnitTests.csproj +++ b/tests/food-market.UnitTests/food-market.UnitTests.csproj @@ -14,6 +14,7 @@ +