feat(hangfire): dashboard + scheduled cleanup джобы (P1-16)

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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-28 10:07:14 +05:00
parent 18d3c3aa1e
commit a74fa114d8
9 changed files with 270 additions and 0 deletions

View file

@ -0,0 +1,46 @@
using Hangfire;
namespace foodmarket.Api.Background;
/// <summary>Регистрирует recurring jobs Hangfire при старте приложения.
/// Идемпотентно: RecurringJob.AddOrUpdate перетирает существующее
/// определение, так что миграции job'ов проходят без ручной очистки таблицы
/// <c>hangfire.recurring_jobs</c>.
///
/// Cron-выражения берутся из конфига (<c>Hangfire:Cron:*</c>) с дефолтом
/// «каждый день в 03:30 UTC» для cleanup-джобов — за 30 минут до бэкапа,
/// чтобы бэкап не цеплял временные блокировки PruneStockMovements.</summary>
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<HousekeepingJobs>(
recurringJobId: "prune-stock-movements",
methodCall: j => j.PruneStockMovementsAsync(CancellationToken.None),
cronExpression: cronStock,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
_jobs.AddOrUpdate<HousekeepingJobs>(
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;
}

View file

@ -0,0 +1,64 @@
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Background;
/// <summary>Hangfire-джобы для регулярной чистки исторических данных:
/// <c>StockMovement</c> старше 2 лет (компенсирует разрастание таблицы при
/// большом обороте продаж), <c>SuperAdminAuditLog</c> старше 90 дней. Период
/// можно поменять через конфиг <c>Hangfire:Retention:*</c>.
///
/// Реализованы как scoped-сервис, чтобы Hangfire разрешил <see cref="AppDbContext"/>
/// и фильтр tenant'а корректно (контекст НЕТ tenant'а — но cleanup идёт без
/// tenant-фильтра через IgnoreQueryFilters, потому что это межтенантная задача).</summary>
public class HousekeepingJobs
{
private readonly AppDbContext _db;
private readonly IConfiguration _cfg;
private readonly ILogger<HousekeepingJobs> _log;
public HousekeepingJobs(AppDbContext db, IConfiguration cfg, ILogger<HousekeepingJobs> log)
{
_db = db;
_cfg = cfg;
_log = log;
}
/// <summary>Удаляет <see cref="StockMovement"/> старше N дней (по умолчанию 730).
/// Stock-инвариант (Stock.Quantity ≡ Σ StockMovement) ломается на удалённых
/// записях, поэтому удаляем ТОЛЬКО действительно старые движения — там
/// невозможно сослаться через unpost (документы уже закрыты).
///
/// Возвращает количество удалённых строк (для логов/мониторинга).</summary>
public async Task<int> 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;
}
/// <summary>Удаляет <c>super_admin_audit_log</c> старше N дней (по умолчанию 90).
/// SuperAdmin'ские действия в каждой орге — отдельный аудит, для compliance
/// храним недолго (квартал) и зачищаем.</summary>
public async Task<int> 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;
}
}

View file

@ -0,0 +1,22 @@
using Hangfire.Dashboard;
namespace foodmarket.Api.Background;
/// <summary>Авторизационный фильтр Hangfire Dashboard. Пускает только
/// аутентифицированных пользователей с ролью SuperAdmin. ClaimsPrincipal
/// берётся из <c>HttpContext.User</c> — то есть стандартный OpenIddict-токен
/// (Bearer) валидируется до этой проверки middleware'ом аутентификации.
///
/// Hangfire по умолчанию пускает только loopback — мы хотим строже:
/// доступ к дашборду = доступ к фоновым джобам всех тенантов, что эквивалентно
/// SuperAdmin-консоли в Web-админке.</summary>
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");
}
}

View file

@ -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<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.AddHostedService<OpenIddictClientSeeder>();
builder.Services.AddHostedService<SystemReferenceSeeder>();
builder.Services.AddHostedService<DevDataSeeder>();
@ -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

View file

@ -21,6 +21,7 @@
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Hangfire.AspNetCore" />
<PackageReference Include="Hangfire.PostgreSql" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
</ItemGroup>
</Project>

View file

@ -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;
/// <summary>В тестах Hangfire-сервер выключен (Hangfire__Enabled=false), и
/// dashboard-маршрут не маппится. Проверяем что /hangfire вообще не отвечает
/// 200/302 без auth — то есть гейт работает на уровне отсутствия маршрута.
/// На проде (Hangfire включён) фильтр SuperAdminHangfireFilter блокирует
/// неавторизованных пользователей. См. SuperAdminHangfireFilter.</summary>
[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);
}
}

View file

@ -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()

View file

@ -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;
/// <summary>Юнит-тест: HousekeepingJobs.PruneStockMovements удаляет только
/// движения старше N дней (по умолчанию 730). Stock-инвариант не проверяем —
/// это межтенантная задача очистки далёкой истории, документы за этот горизонт
/// в норме закрыты.</summary>
public class HousekeepingJobsTests
{
private static IConfiguration CfgWith(int stockDays = 730, int auditDays = 90) =>
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["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<HousekeepingJobs>.Instance);
deleted = await jobs.PruneStockMovementsAsync();
}
deleted.Should().Be(1);
using (var db = sqlite.Create(tenant))
{
(await db.StockMovements.IgnoreQueryFilters().CountAsync()).Should().Be(1);
}
}
}

View file

@ -14,6 +14,7 @@
<ProjectReference Include="..\..\src\food-market.domain\food-market.domain.csproj" />
<ProjectReference Include="..\..\src\food-market.application\food-market.application.csproj" />
<ProjectReference Include="..\..\src\food-market.infrastructure\food-market.infrastructure.csproj" />
<ProjectReference Include="..\..\src\food-market.api\food-market.api.csproj" />
</ItemGroup>
<ItemGroup>