Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,215 @@
using Deal.Api.Events;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Hosting;
/// <summary>
/// Фоновый цикл SSE-алертов ИИ-бюджета (Ruling 3, Task 9; эталон StorageTickScheduler): каждые 60 с
/// обходит ВСЕ тенанты реестра и публикует в канал тенанта тост при пересечении порогов бюджета 80%/100%.
/// </summary>
/// <remarks>
/// Пересечение порога детектируется CAS-установкой флагов Warned80/NotifiedExhausted
/// (<see cref="ITenantLimitStore.TryMarkWarnedAsync"/> / <see cref="ITenantLimitStore.TryMarkNotifiedExhaustedAsync"/>, Task 8): метод возвращает
/// true только в момент «флаг ещё не стоял и порог достигнут» — публикация выполняется ровно один раз на порог
/// за период (флаги сбрасываются ленивым reset периода и сменой бюджета оператором, Task 8/10). Флаги выставляет
/// ТОЛЬКО TryMark* (review-fix Task 9): AddUsage только инкрементирует UsedTokens, поэтому естественный расход,
/// пересекший порог, планировщик видит на ближайшем проходе как непомеченный переход — TryMark* возвращает true
/// ровно один раз, и тост не теряется и не задваивается.
/// Тексты и иконка
/// 1:1 с Ruling 3 («ИИ-бюджет израсходован на 80%» / «ИИ-бюджет исчерпан — обработка в локальном режиме», icon
/// bell); SSE-тип — существующий 'toast' (Ruling 11: новых SSE-типов нет). Списывание usage выполняется в
/// gRPC-адаптерах (TokenUsageRecorder) и флаги при пересечении уже могут стоять — TryMark*-CAS не даёт
/// задвоить тост. Публикация в канал тенанта; без подписчиков — no-op, не падает (Ruling 5).
/// <para>
/// Лимиты живут в публичной схеме (ITenantLimitStore → DealDbContext) — в отличие от StorageTickScheduler
/// tenant-контекст проходу не нужен (SetTenant не выполняется); проверка каждого тенанта — в собственном scope
/// (scoped-хранилище лимитов). Первый проход — сразу после старта, далее по таймеру; перекрывающиеся проходы
/// исключены in-flight guard (Interlocked, как RatesRefreshScheduler). Ошибки логируются и наружу не выбрасываются
/// (проверка одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий проход
/// отменяется (graceful).
/// </para>
/// </remarks>
public sealed class BudgetAlertScheduler : IHostedService
{
/// <summary>
/// Период проходов цикла — 60 с (план Task 9: фоновая проверка порогов бюджета).
/// </summary>
public const int AlertPeriodSeconds = 60;
// Тип SSE-события тоста (Ruling 5/11; api.js слушает 'toast', новых типов не вводим).
private const string ToastEventType = "toast";
// Текст тоста пересечения порога 80% (Ruling 3).
private const string Warned80ToastText = "ИИ-бюджет израсходован на 80%";
// Текст тоста исчерпания бюджета (Ruling 3).
private const string ExhaustedToastText = "ИИ-бюджет исчерпан — обработка в локальном режиме";
// Иконка тостов бюджета (Ruling 3: bell).
private const string BellIcon = "bell";
private static readonly TimeSpan AlertPeriod = TimeSpan.FromSeconds(AlertPeriodSeconds);
private readonly IServiceScopeFactory _scopeFactory;
private readonly SseBroker _broker;
private readonly ILogger<BudgetAlertScheduler> _logger;
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен).
private readonly CancellationTokenSource _shutdownCts = new();
private Timer? _timer;
private Task? _currentIteration;
private int _iterationInProgress;
/// <summary>
/// Создаёт планировщик фоновых алертов ИИ-бюджета.
/// </summary>
/// <param name="scopeFactory">Фабрика scope: проход цикла и проверка каждого тенанта — в собственных scope.</param>
/// <param name="broker">SSE-брокер каналов тенантов (публикация тостов; без подписчиков — no-op).</param>
/// <param name="logger">Логгер ошибок цикла.</param>
public BudgetAlertScheduler(
IServiceScopeFactory scopeFactory,
SseBroker broker,
ILogger<BudgetAlertScheduler> logger)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentNullException.ThrowIfNull(broker);
ArgumentNullException.ThrowIfNull(logger);
_scopeFactory = scopeFactory;
_broker = broker;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken ct)
{
// Первый проход — сразу после старта (как StorageTickScheduler), далее каждые 60 с.
_timer = new Timer(
static state => ((BudgetAlertScheduler)state!).RunIteration(),
this,
TimeSpan.Zero,
AlertPeriod);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken ct)
{
// Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита
// остановки хоста (HostOptions.ShutdownTimeout).
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
_timer?.Dispose();
_timer = null;
_shutdownCts.Cancel();
Task? iteration = Volatile.Read(ref _currentIteration);
if (iteration is not null)
{
try
{
await iteration.WaitAsync(ct);
}
catch (OperationCanceledException)
{
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
// RunCycleCoreAsync, незавершённый проход безопасно завершится на отменённом токене.
}
}
}
/// <summary>
/// Один проход цикла: список тенантов реестра и проверка порогов каждого (no-op, если проход идёт).
/// </summary>
/// <remarks>Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются); таймер
/// вызывает этот же метод. Ошибки и отмена токена наружу не выбрасываются: сбои логируются (цикл живёт),
/// отмена по токену останова завершает проход штатно.</remarks>
/// <param name="ct">Токен отмены прохода (в проде — токен остановки хоста).</param>
/// <returns>Задача прохода (завершается без исключений).</returns>
public Task RunCycleAsync(CancellationToken ct)
{
if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0)
{
return Task.CompletedTask;
}
Task iteration = RunCycleCoreAsync(ct);
Volatile.Write(ref _currentIteration, iteration);
return iteration;
}
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
private void RunIteration()
{
_ = RunCycleAsync(_shutdownCts.Token);
}
// Тело прохода: проверка порогов каждого тенанта реестра; guard сбрасывается в finally.
// ct: Токен отмены (остановка хоста).
private async Task RunCycleCoreAsync(CancellationToken ct)
{
try
{
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
foreach (TenantRecordDto tenant in tenants)
{
await CheckTenantAsync(tenant, ct);
}
}
catch (OperationCanceledException)
{
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
}
catch (Exception exception)
{
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
_logger.LogError(exception, "Цикл бюджет-алертов: проход по тенантам не удался");
}
finally
{
Interlocked.Exchange(ref _iterationInProgress, 0);
}
}
// Проверка порогов одного тенанта в собственном scope: TryMark* (CAS) → тост в канал тенанта.
// Список тенантов и лимиты живут в публичной схеме — tenant-контекст не заполняется (в отличие от
// тиков StorageTickScheduler, работающих со схемой тенанта). Порядок: сначала 80% (Warned80), затем 100%
// (NotifiedExhausted) — при скачке через оба порога за период выходят оба тоста по одному разу. Сбой одного
// тенанта не валит проход: ошибка логируется, остальные тенанты обрабатываются; отмена (OCE) пробрасывается
// наверх — проход завершается.
// tenant: Тенант реестра (Id в формате Guid).
// ct: Токен отмены прохода.
private async Task CheckTenantAsync(TenantRecordDto tenant, CancellationToken ct)
{
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
try
{
ITenantLimitStore limitStore = tenantScope.ServiceProvider.GetRequiredService<ITenantLimitStore>();
// TryMark* — атомарная установка флага: true только в момент первого наблюдения порога за период
// (Task 8/9). Тост публикуется ровно один раз на порог; повторные проходы — false → no-op.
if (await limitStore.TryMarkWarnedAsync(tenant.Id, ct))
{
_logger.LogDebug("Бюджет тенанта {TenantId}: порог 80% пересечён — SSE-тост", tenant.Id);
_broker.Publish(tenant.Id, ToastEventType, new { text = Warned80ToastText, icon = BellIcon });
}
if (await limitStore.TryMarkNotifiedExhaustedAsync(tenant.Id, ct))
{
_logger.LogDebug("Бюджет тенанта {TenantId}: исчерпан — SSE-тост", tenant.Id);
_broker.Publish(tenant.Id, ToastEventType, new { text = ExhaustedToastText, icon = BellIcon });
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Цикл бюджет-алертов: проверка тенанта {TenantId} не удалась", tenant.Id);
}
}
}
@@ -0,0 +1,175 @@
using Deal.Api.Configuration;
using Deal.Modules.Tenants.Application;
namespace Deal.Api.Hosting;
/// <summary>
/// Фоновый цикл авто-очистки данных (этап 12, пакет B; эталон DealMetricsCollector): раз в сутки
/// удаляет устаревшие записи аудита по retention, сбрасывает накопительные поля лимитов прошедших периодов
/// и убирает завершившиеся окна распределённых счётчиков rate limiting.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>аудит — <see cref="IAuditLogStore.PurgeOlderThanAsync"/> по границе
/// <c>now DataRetention:AuditRetentionDays</c> (дефолт 180 дней);</item>
/// <item>лимиты — <see cref="ITenantLimitStore.ResetExpiredPeriodsAsync"/>: отдельной истории периодов нет,
/// поэтому очищаются накопительные поля (UsedTokens/флаги) строк с завершившимся периодом;</item>
/// <item>счётчики — <see cref="IRateLimitCounterStore.DeleteExpiredAsync"/> удаляет строки с истёкшим
/// ExpiresAt (окна rate limiting и попыток входа).</item>
/// </list>
/// Идемпотентно: повторный проход на тех же данных не находит, что чистить. Ошибки логируются и наружу
/// не выбрасываются (сбой очистки не валит хост); перекрывающиеся проходы исключены in-flight guard
/// (Interlocked); при остановке хоста таймер останавливается и текущий проход отменяется.
/// </remarks>
public sealed class DataRetentionScheduler : IHostedService
{
/// <summary>
/// Период проходов цикла — 24 часа (авто-очистка редко меняющихся данных).
/// </summary>
public const int RunPeriodHours = 24;
/// <summary>
/// Задержка первого прохода — 60 с (после bootstrap/провижининга, без конкуренции со стартом).
/// </summary>
public const int InitialDelaySeconds = 60;
/// <summary>
/// Фолбэк срока хранения аудита в днях, если конфигурация задала неположительное значение.
/// </summary>
public const int DefaultAuditRetentionDays = 180;
private readonly IServiceScopeFactory _scopeFactory;
private readonly DataRetentionOptions _options;
private readonly ILogger<DataRetentionScheduler> _logger;
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен).
private readonly CancellationTokenSource _shutdownCts = new();
private Timer? _timer;
private Task? _currentIteration;
private int _iterationInProgress;
/// <summary>
/// Создаёт фоновый цикл авто-очистки.
/// </summary>
/// <param name="scopeFactory">Фабрика scope: проход — в собственном scope (scoped-хранилища).</param>
/// <param name="options">Настройки retention (секция DataRetention).</param>
/// <param name="logger">Логгер ошибок/итогов цикла.</param>
public DataRetentionScheduler(
IServiceScopeFactory scopeFactory,
DataRetentionOptions options,
ILogger<DataRetentionScheduler> logger)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
_scopeFactory = scopeFactory;
_options = options;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken ct)
{
if (!_options.Enabled)
{
_logger.LogInformation("Авто-очистка данных выключена (DataRetention:Enabled=false)");
return Task.CompletedTask;
}
_timer = new Timer(
static state => ((DataRetentionScheduler)state!).RunIteration(),
this,
TimeSpan.FromSeconds(InitialDelaySeconds),
TimeSpan.FromHours(RunPeriodHours));
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken ct)
{
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
_timer?.Dispose();
_timer = null;
_shutdownCts.Cancel();
Task? iteration = Volatile.Read(ref _currentIteration);
if (iteration is not null)
{
try
{
await iteration.WaitAsync(ct);
}
catch (OperationCanceledException)
{
// Лимит остановки истёк — ошибки прохода проглочены внутри RunCycleCoreAsync.
}
}
}
/// <summary>
/// Один проход очистки (no-op, если проход уже идёт); публичен как точка запуска для тестов.
/// </summary>
/// <param name="ct">Токен отмены прохода.</param>
/// <returns>Задача прохода (завершается без исключений).</returns>
public Task RunCycleAsync(CancellationToken ct)
{
if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0)
{
return Task.CompletedTask;
}
Task iteration = RunCycleCoreAsync(ct);
Volatile.Write(ref _currentIteration, iteration);
return iteration;
}
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
private void RunIteration()
{
_ = RunCycleAsync(_shutdownCts.Token);
}
// Тело прохода: аудит по retention + сброс лимитов прошедших периодов + уборка счётчиков.
// ct: Токен отмены (остановка хоста).
private async Task RunCycleCoreAsync(CancellationToken ct)
{
try
{
await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope();
DateTimeOffset now = DateTimeOffset.UtcNow;
int retentionDays = _options.AuditRetentionDays > 0
? _options.AuditRetentionDays
: DefaultAuditRetentionDays;
var auditStore = scope.ServiceProvider.GetRequiredService<IAuditLogStore>();
int purgedAudit = await auditStore.PurgeOlderThanAsync(now.AddDays(-retentionDays), ct);
var limitStore = scope.ServiceProvider.GetRequiredService<ITenantLimitStore>();
int resetLimits = await limitStore.ResetExpiredPeriodsAsync(now, ct);
var counterStore = scope.ServiceProvider.GetRequiredService<IRateLimitCounterStore>();
int purgedCounters = await counterStore.DeleteExpiredAsync(now, ct);
_logger.LogInformation(
"Авто-очистка данных: аудит {Audit} записей старше {Days} дн., сброшено лимитов {Limits}, "
+ "убрано счётчиков {Counters}",
purgedAudit,
retentionDays,
resetLimits,
purgedCounters);
}
catch (OperationCanceledException)
{
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
}
catch (Exception exception)
{
_logger.LogError(exception, "Авто-очистка данных: проход не удался");
}
finally
{
Interlocked.Exchange(ref _iterationInProgress, 0);
}
}
}
@@ -0,0 +1,185 @@
using Deal.Modules.Discovery.Application;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants;
namespace Deal.Api.Hosting;
/// <summary>
/// Фоновый цикл Discovery-воркера по всем тенантам (план Task 18, Ruling 10; эталон PipelineWorkerScheduler/StorageTickScheduler).
/// </summary>
/// <remarks>
/// Каждые 5 с (в прототипе — <c>_discovery_loop</c> main.py: tick раз в ~5 секунд) обходит ВСЕ тенанты
/// системного реестра и для каждого выполняет один тик <see cref="DiscoveryWorkerService.TickOnceAsync"/> в
/// собственном scope с <c>ITenantContext.SetTenant</c> (эталон StorageTickScheduler). Тик делает ОДНО действие
/// (поиск/оценка/вступление/done) для самой старой running-задачи тенанта — 1:1 с discovery_worker.tick L444484.
/// Как StorageTickScheduler: первый проход — сразу после старта, далее по таймеру; перекрывающиеся проходы
/// исключены in-flight guard (Interlocked) — следующее срабатывание пропускается, если проход длится дольше
/// периода (в частности, при паузах авто-вступлений 50–70 с внутри тика). Ошибки логируются и наружу не
/// выбрасываются (тик одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий
/// проход отменяется (graceful). Пустой проход (нет running-задач/пауза/flood) — тихий no-op (action none).
/// </remarks>
public sealed class DiscoveryWorkerScheduler : IHostedService
{
// Период тиков цикла — 5 с (в прототипе _discovery_loop: tick каждые ~5 секунд).
private const int DiscoveryPeriodSeconds = 5;
private static readonly TimeSpan DiscoveryPeriod = TimeSpan.FromSeconds(DiscoveryPeriodSeconds);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<DiscoveryWorkerScheduler> _logger;
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы тика наблюдают токен).
private readonly CancellationTokenSource _shutdownCts = new();
private Timer? _timer;
private Task? _currentIteration;
private int _iterationInProgress;
/// <summary>
/// Создаёт планировщик фонового цикла Discovery.
/// </summary>
/// <param name="scopeFactory">Фабрика scope: проход цикла и тик каждого тенанта — в собственных scope.</param>
/// <param name="logger">Логгер ошибок цикла.</param>
public DiscoveryWorkerScheduler(IServiceScopeFactory scopeFactory, ILogger<DiscoveryWorkerScheduler> logger)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentNullException.ThrowIfNull(logger);
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken ct)
{
// Первый проход — сразу после старта (как StorageTickScheduler), далее каждые 5 с.
_timer = new Timer(
static state => ((DiscoveryWorkerScheduler)state!).RunIteration(),
this,
TimeSpan.Zero,
DiscoveryPeriod);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken ct)
{
// Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита
// остановки хоста (HostOptions.ShutdownTimeout).
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
_timer?.Dispose();
_timer = null;
_shutdownCts.Cancel();
Task? iteration = Volatile.Read(ref _currentIteration);
if (iteration is not null)
{
try
{
await iteration.WaitAsync(ct);
}
catch (OperationCanceledException)
{
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
// RunCycleAsync, незавершённый проход безопасно завершится на отменённом токене.
}
}
}
/// <summary>
/// Один проход цикла: список тенантов реестра и тик каждого (no-op, если проход уже идёт).
/// </summary>
/// <remarks>Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и
/// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не
/// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно.</remarks>
/// <param name="ct">Токен отмены прохода (в проде — токен остановки хоста).</param>
/// <returns>Задача прохода (завершается без исключений).</returns>
public Task RunCycleAsync(CancellationToken ct)
{
if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0)
{
return Task.CompletedTask;
}
Task iteration = RunCycleCoreAsync(ct);
Volatile.Write(ref _currentIteration, iteration);
return iteration;
}
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
private void RunIteration()
{
_ = RunCycleAsync(_shutdownCts.Token);
}
// Тело прохода: тик Discovery каждого тенанта реестра; guard сбрасывается в finally.
// ct: Токен отмены (остановка хоста).
private async Task RunCycleCoreAsync(CancellationToken ct)
{
try
{
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
foreach (TenantRecordDto tenant in tenants)
{
await TickTenantAsync(tenant, ct);
}
}
catch (OperationCanceledException)
{
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
}
catch (Exception exception)
{
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
_logger.LogError(exception, "Цикл Discovery: проход по тенантам не удался");
}
finally
{
Interlocked.Exchange(ref _iterationInProgress, 0);
}
}
// Тик одного тенанта в собственном scope: SetTenant → один тик воркера; Reset в finally.
// Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как
// SessionMiddleware). Тик одного тенанта не валит проход: ошибка логируется, остальные тенанты
// обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается. Результат «none» — тихий
// no-op (пауза/нет работы); прочие действия логируются в Debug (рабочие события — в DiscLog задачи).
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_&lt;N&gt;).
// ct: Токен отмены прохода.
private async Task TickTenantAsync(TenantRecordDto tenant, CancellationToken ct)
{
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
try
{
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
// Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
DiscoveryWorkerService worker = tenantScope.ServiceProvider.GetRequiredService<DiscoveryWorkerService>();
DiscoveryWorkerOutcome outcome = await worker.TickOnceAsync(ct);
if (outcome.Action != DiscoveryWorkerService.ActionNone)
{
_logger.LogDebug(
"Discovery-тик {TenantId}: {Action} (задача {TaskId})",
tenant.Id,
outcome.Action,
outcome.TaskId ?? "-");
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Цикл Discovery: тик тенанта {TenantId} не удался", tenant.Id);
}
finally
{
tenantContext.Reset();
}
}
}
@@ -0,0 +1,231 @@
using Deal.Infrastructure.Integrations;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants;
namespace Deal.Api.Hosting;
/// <summary>
/// Фоновый флашер очереди обучения ML — выгрузка MlOutbox в ml-service батчами (план Task 16, Ruling 6).
/// </summary>
/// <remarks>
/// Аналог <c>_ml_sync_loop</c> python-прототипа и <c>flush_outbox</c> (ml_client.py L5682): каждые 10 с
/// обходит ВСЕ тенанты реестра и в собственном scope с <c>ITenantContext.SetTenant</c> (эталон
/// PipelineWorkerScheduler/StorageTickScheduler) отправляет накопленное обучение RPC TrainBatch порциями по
/// 10 строк, ≤100 за цикл. Строки удаляются ТОЛЬКО после успешного батча (python L78–79); при недоступности
/// ml-service порция остаётся и уходит в следующий цикл (ретрай на каждом тике, «строки остаются» — Ruling 6).
/// <para>
/// Регистрируется в Deal.Api только при <c>Services:Ml:UseLocal=false</c> (gRPC-режим): Local-режиму
/// ml-service не нужен — очередь копится (этап 3), а при «поднятом сервисе» флашер выгружает её сразу.
/// Первый проход — сразу после старта (как PipelineWorkerScheduler); перекрывающиеся проходы исключены
/// in-flight guard (Interlocked). Ошибки логируются и наружу не выбрасываются (флаш одного тенанта не
/// валит цикл — остальные тенанты обрабатываются); при остановке хоста таймер останавливается и текущий
/// проход отменяется (graceful). Пустая очередь — тихий no-op.
/// </para>
/// </remarks>
public sealed class MlOutboxFlushScheduler : IHostedService
{
/// <summary>
/// Период циклов выгрузки — 10 с (Ruling 6).
/// </summary>
public const int FlushPeriodSeconds = 10;
/// <summary>
/// Размер порции за один TrainBatch — 10 строк (ml_client.flush_outbox L63: chunk=10).
/// </summary>
public const int BatchSize = 10;
/// <summary>
/// Потолок выгрузки за один цикл тенанта — 100 строк (ml_client.flush_outbox L56: batch=100).
/// </summary>
public const int MaxPerCycle = 100;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<MlOutboxFlushScheduler> _logger;
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен).
private readonly CancellationTokenSource _shutdownCts = new();
private Timer? _timer;
private Task? _currentIteration;
private int _iterationInProgress;
/// <summary>
/// Создаёт планировщик фоновой выгрузки очереди обучения ML.
/// </summary>
/// <param name="scopeFactory">Фабрика scope: проход цикла и флаш каждого тенанта — в собственных scope.</param>
/// <param name="logger">Логгер сбоев цикла.</param>
public MlOutboxFlushScheduler(IServiceScopeFactory scopeFactory, ILogger<MlOutboxFlushScheduler> logger)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentNullException.ThrowIfNull(logger);
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken ct)
{
// Первый проход — сразу после старта (как PipelineWorkerScheduler), далее каждые 10 с.
_timer = new Timer(
static state => ((MlOutboxFlushScheduler)state!).RunIteration(),
this,
TimeSpan.Zero,
TimeSpan.FromSeconds(FlushPeriodSeconds));
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken ct)
{
// Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита
// остановки хоста (HostOptions.ShutdownTimeout).
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
_timer?.Dispose();
_timer = null;
_shutdownCts.Cancel();
Task? iteration = Volatile.Read(ref _currentIteration);
if (iteration is not null)
{
try
{
await iteration.WaitAsync(ct);
}
catch (OperationCanceledException)
{
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
// FlushCycleCoreAsync, незавершённый проход безопасно завершится на отменённом токене.
}
}
}
/// <summary>
/// Один проход цикла: список тенантов реестра и выгрузка каждого (no-op, если проход уже идёт).
/// </summary>
/// <remarks>Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются);
/// таймер вызывает этот же метод. Ошибки и отмена токена наружу не выбрасываются: сбои логируются
/// (цикл живёт), отмена по токену останова завершает проход штатно.</remarks>
/// <param name="ct">Токен отмены прохода (в проде — токен остановки хоста).</param>
/// <returns>Задача прохода (завершается без исключений).</returns>
public Task RunCycleAsync(CancellationToken ct)
{
if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0)
{
return Task.CompletedTask;
}
Task iteration = FlushCycleCoreAsync(ct);
Volatile.Write(ref _currentIteration, iteration);
return iteration;
}
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
private void RunIteration()
{
_ = RunCycleAsync(_shutdownCts.Token);
}
// Тело прохода: выгрузка очереди каждого тенанта реестра; guard сбрасывается в finally.
// ct: Токен отмены (остановка хоста).
private async Task FlushCycleCoreAsync(CancellationToken ct)
{
try
{
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
foreach (TenantRecordDto tenant in tenants)
{
await FlushTenantAsync(tenant, ct);
}
}
catch (OperationCanceledException)
{
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
}
catch (Exception exception)
{
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
_logger.LogError(exception, "Флашер ML-outbox: проход по тенантам не удался");
}
finally
{
Interlocked.Exchange(ref _iterationInProgress, 0);
}
}
// Выгрузка очереди одного тенанта в собственном scope: SetTenant → порции по 10 до ≤100/цикл.
// Строки удаляются только после успешного TrainBatch (Ruling 6); сбой батча — порция остаётся,
// цикл тенанта завершается (следующая попытка — следующий тик). Сбой хранилища тенанта не валит проход:
// ошибка логируется, остальные тенанты обрабатываются; отмена (OCE) пробрасывается наверх.
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_&lt;N&gt;).
// ct: Токен отмены прохода.
private async Task FlushTenantAsync(TenantRecordDto tenant, CancellationToken ct)
{
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
try
{
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
// Resolve ПОСЛЕ SetTenant: IMlLearningStore строится от схемы текущего тенанта (как в цикле pump).
IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService<IMlLearningStore>();
IMlTrainClient trainClient = tenantScope.ServiceProvider.GetRequiredService<IMlTrainClient>();
int total = 0;
while (total < MaxPerCycle)
{
IReadOnlyList<MlOutboxEntryDto> rows = await learningStore.TakeOutboxBatchAsync(BatchSize, ct);
if (rows.Count == 0)
{
break;
}
try
{
await trainClient.TrainBatchAsync(rows, ct);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
// Недоступность/сбой ml-service: порция остаётся в очереди (python L75–77), следующая
// попытка — на следующем тике; флашер не роняет проход цикла.
_logger.LogWarning(
exception,
"Флашер ML-outbox: отправка {RowCount} строк тенанта {TenantId} не удалась — строки остались",
rows.Count,
tenant.Id);
break;
}
// Удаление только после успеха (python L78–79): отправленные строки больше не нужны.
await learningStore.DeleteOutboxAsync(rows.Select(row => row.Id).ToList(), ct);
total += rows.Count;
}
if (total > 0)
{
_logger.LogInformation("Флашер ML-outbox: выгружено {RowCount} строк тенанта {TenantId}", total, tenant.Id);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Флашер ML-outbox: выгрузка тенанта {TenantId} не удалась", tenant.Id);
}
finally
{
tenantContext.Reset();
}
}
}
@@ -0,0 +1,96 @@
using Deal.Modules.Tenants.Application;
namespace Deal.Api.Hosting;
/// <summary>
/// Hosted-шаг bootstrap оператора при старте (Ruling 1 этапа 7): env DEAL_OPERATOR_* → OperatorBootstrapService.EnsureOperatorAsync.
/// </summary>
/// <remarks>
/// Регистрируется после <see cref="TenantBootstrapService"/> и работает тем же паттерном: OperatorBootstrapService —
/// scoped (его IOperatorAuthStore живёт на scoped DealDbContext), поэтому резолвится в собственном scope из
/// IServiceScopeFactory (как TenantService в TenantBootstrapService). Идемпотентен: существующего оператора
/// не пересоздаёт и пароль не перезаписывает. В Development без env-кред используется dev-дефолт
/// operator/operator (зеркало dev-seed admin/admin, логируется как dev-режим); в Production без env-кред
/// шаг пропускается с warning — оператора заводит админ позже через env и рестарт хоста (кода регистрации
/// оператора нет). Частичная конфигурация (задана ровно одна из DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD)
/// логируется warning — не молчаливый дефолт (решение ревью Task 2): в Development используются dev-дефолты,
/// в Production шаг пропускается. Секреты (пароли) в логи не пишутся (правило этапов 1–6).
/// </remarks>
public sealed class OperatorBootstrapHostedService(
IServiceScopeFactory scopeFactory,
IConfiguration configuration,
IHostEnvironment environment,
ILogger<OperatorBootstrapHostedService> logger) : IHostedService
{
/// <inheritdoc />
public async Task StartAsync(CancellationToken ct)
{
await using var scope = scopeFactory.CreateAsyncScope();
var operatorBootstrapService = scope.ServiceProvider.GetRequiredService<OperatorBootstrapService>();
var login = configuration[OperatorBootstrapService.LoginEnvKey];
var password = configuration[OperatorBootstrapService.PasswordEnvKey];
bool hasLogin = !string.IsNullOrWhiteSpace(login);
bool hasPassword = !string.IsNullOrWhiteSpace(password);
bool allowDevelopmentDefaults = environment.IsDevelopment();
// Предупреждения о пропуске/частичной конфигурации логируются ДО вызова шага — сам шаг
// в этих случаях ничего не создаёт (EnsureOperatorAsync возвращает null, хранилище не тронуто).
if (!hasLogin || !hasPassword)
{
if (hasLogin != hasPassword)
{
LogPartialConfigurationWarning(hasLogin, allowDevelopmentDefaults);
}
else if (!allowDevelopmentDefaults)
{
// Production без кред: пропуск с warning (Ruling 1) — кода регистрации оператора нет.
logger.LogWarning(
"DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Production) — bootstrap оператора "
+ "пропущен. Оператор заводится позже: задайте env DEAL_OPERATOR_* и перезапустите хост.");
}
else
{
// Development без кред: штатный dev-дефолт (зеркало dev-seed admin/admin).
logger.LogInformation(
"DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Development) — используется "
+ "dev-дефолт оператора {DefaultLogin}.",
OperatorBootstrapService.DefaultOperatorLogin);
}
}
var ensuredLogin = await operatorBootstrapService.EnsureOperatorAsync(
login, password, allowDevelopmentDefaults, ct);
if (ensuredLogin is not null)
{
logger.LogInformation("Bootstrap оператора: оператор {Login} присутствует.", ensuredLogin);
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
// Логирует warning о неполной env-конфигурации (задана одна из двух переменных).
// hasLogin: Задан ли логин (пароль при этом пуст).
// allowDevelopmentDefaults: Разрешены ли dev-дефолты (Development).
private void LogPartialConfigurationWarning(bool hasLogin, bool allowDevelopmentDefaults)
{
string missingEnv = hasLogin
? OperatorBootstrapService.PasswordEnvKey
: OperatorBootstrapService.LoginEnvKey;
if (allowDevelopmentDefaults)
{
logger.LogWarning(
"Env-конфигурация оператора неполна: не задан {MissingEnv}. В Development используются "
+ "dev-дефолты оператора {DefaultLogin}.",
missingEnv, OperatorBootstrapService.DefaultOperatorLogin);
}
else
{
logger.LogWarning(
"Env-конфигурация оператора неполна: не задан {MissingEnv}. Bootstrap оператора в Production "
+ "пропущен — оператор заводится позже через env DEAL_OPERATOR_* и рестарт хоста.",
missingEnv);
}
}
}
@@ -0,0 +1,240 @@
using Deal.Api.Events;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants;
namespace Deal.Api.Hosting;
/// <summary>
/// Фоновый цикл правил хранения по всем тенантам (план Tasks 1112, Rulings 3/8; аналог _storage_loop main.py L4353).
/// </summary>
/// <remarks>
/// Каждые 30 с (в прототипе — <c>asyncio.sleep(30)</c>, main.py L53) обходит ВСЕ тенанты системного
/// реестра. Проход открывает собственный scope (реестр живёт в публичной схеме — ITenantRepository вне
/// tenant-контекста, паттерн TenantBootstrapService), на каждый тенант — вложенный scope с
/// ITenantContext.SetTenant (эталон SessionMiddleware/TenantBootstrapService) и StorageTickService.TickAsync,
/// после чего — автоочистка отсева пайплайна (<see cref="PipelineProcessingService.PurgeExpiredAsync"/>, 3 суток;
/// Task 11, Ruling 8/9: фоновый аналог тика AdminTickOrchestrator, tick_storage L485493), SSE-тосты
/// статистики в канал тенанта (StorageToastPublisher; без подписчиков — no-op, Ruling 5) и проверка
/// наступивших напоминаний «Отложено» (CardsService.CheckDueRemindersAsync + SSE reminder_due, Task 12,
/// Ruling 3/8 — фоновый аналог ветки AdminTickOrchestrator, check_reminders из _storage_loop main.py L49).
/// Порядок тика тенанта 1:1 с _storage_loop main.py L4753: тик → тосты → напоминания. Как в прототипе,
/// первый проход выполняется сразу после старта (тик до первого sleep), далее — по таймеру. Параллельные
/// проходы исключены in-flight guard (Interlocked, как RatesRefreshScheduler): если проход длится дольше
/// периода, следующее срабатывание таймера пропускается. Ошибки логируются и наружу не выбрасываются
/// (тик одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий проход
/// отменяется (graceful).
/// </remarks>
public sealed class StorageTickScheduler : IHostedService
{
// Период проходов цикла — 30 с, 1:1 с _storage_loop main.py L53 (asyncio.sleep(30)).
private const int TickPeriodSeconds = 30;
// SSE-тип события «выстрелившего» напоминания «Отложено» (Ruling 8, api-map §2: {id,title,containerId}).
private const string ReminderDueEventType = "reminder_due";
private static readonly TimeSpan TickPeriod = TimeSpan.FromSeconds(TickPeriodSeconds);
private readonly IServiceScopeFactory _scopeFactory;
private readonly StorageToastPublisher _toastPublisher;
private readonly SseBroker _broker;
private readonly ILogger<StorageTickScheduler> _logger;
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы тика наблюдают токен).
private readonly CancellationTokenSource _shutdownCts = new();
private Timer? _timer;
private Task? _currentIteration;
private int _iterationInProgress;
/// <summary>
/// Создаёт планировщик фонового цикла правил хранения.
/// </summary>
/// <param name="scopeFactory">Фабрика scope: проход цикла и тик каждого тенанта — в собственных scope.</param>
/// <param name="toastPublisher">Публикатор SSE-тостов статистики тика (общий с POST /api/admin/tick).</param>
/// <param name="broker">SSE-брокер каналов тенантов: reminder_due «выстреливших» напоминаний в канал тенанта
/// (как StorageToastPublisher; без подписчиков публикация — no-op, Ruling 5).</param>
/// <param name="logger">Логгер ошибок цикла.</param>
public StorageTickScheduler(
IServiceScopeFactory scopeFactory,
StorageToastPublisher toastPublisher,
SseBroker broker,
ILogger<StorageTickScheduler> logger)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentNullException.ThrowIfNull(toastPublisher);
ArgumentNullException.ThrowIfNull(broker);
ArgumentNullException.ThrowIfNull(logger);
_scopeFactory = scopeFactory;
_toastPublisher = toastPublisher;
_broker = broker;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken ct)
{
// Первый проход — сразу после старта (в прототипе тик выполняется до первого sleep); далее каждые 30 с.
_timer = new Timer(
static state => ((StorageTickScheduler)state!).RunIteration(),
this,
TimeSpan.Zero,
TickPeriod);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken ct)
{
// Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита
// остановки хоста (HostOptions.ShutdownTimeout).
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
_timer?.Dispose();
_timer = null;
_shutdownCts.Cancel();
Task? iteration = Volatile.Read(ref _currentIteration);
if (iteration is not null)
{
try
{
await iteration.WaitAsync(ct);
}
catch (OperationCanceledException)
{
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
// RunCycleAsync, незавершённый проход безопасно завершится на отменённом токене.
}
}
}
/// <summary>
/// Один проход цикла: список тенантов реестра и тик каждого (no-op, если проход уже идёт).
/// </summary>
/// <remarks>Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и
/// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не
/// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно.</remarks>
/// <param name="ct">Токен отмены прохода (в проде — токен остановки хоста).</param>
/// <returns>Задача прохода (завершается без исключений).</returns>
public Task RunCycleAsync(CancellationToken ct)
{
if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0)
{
return Task.CompletedTask;
}
Task iteration = RunCycleCoreAsync(ct);
Volatile.Write(ref _currentIteration, iteration);
return iteration;
}
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
private void RunIteration()
{
_ = RunCycleAsync(_shutdownCts.Token);
}
// Тело прохода: тик каждого тенанта реестра; guard сбрасывается в finally.
// ct: Токен отмены (остановка хоста).
private async Task RunCycleCoreAsync(CancellationToken ct)
{
try
{
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
foreach (TenantRecordDto tenant in tenants)
{
await TickTenantAsync(tenant, ct);
}
}
catch (OperationCanceledException)
{
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
}
catch (Exception exception)
{
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
_logger.LogError(exception, "Цикл правил хранения: проход по тенантам не удался");
}
finally
{
Interlocked.Exchange(ref _iterationInProgress, 0);
}
}
// Тик одного тенанта в собственном scope: SetTenant → Kanban-тик → purge отсева → SSE-тосты →
// проверка напоминаний + SSE reminder_due; Reset в finally (1:1 с _storage_loop main.py L4753: тик →
// тосты → напоминания).
// Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как
// SessionMiddleware). Тик одного тенанта не валит проход: ошибка ветки/тенанта логируется, остальные
// тенанты обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается.
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_&lt;N&gt;).
// ct: Токен отмены прохода.
private async Task TickTenantAsync(TenantRecordDto tenant, CancellationToken ct)
{
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
try
{
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
// Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
StorageTickService tickService = tenantScope.ServiceProvider.GetRequiredService<StorageTickService>();
StorageTickStatsDto stats = await tickService.TickAsync(ct);
// Автоочистка отсева пайплайна: записи старше 3 суток — безвозвратно (Task 11, Ruling 8/9;
// tick_storage L485493). Счётчик вливается в storage.purgedRejected — как ручной тик
// (AdminTickOrchestrator), тост «Отсев очищен: N записей (3 дн.)» публикуется этой же веткой.
PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService<PipelineProcessingService>();
int purgedRejected = await processing.PurgeExpiredAsync(ct);
StorageTickStatsDto mergedStats = stats with { PurgedRejected = purgedRejected };
// Тосты — в канал тенанта (публикация из Api-слоя, Ruling 5; без подписчиков — no-op).
_toastPublisher.PublishTickToasts(tenant.Id, mergedStats);
// Проверка наступивших напоминаний «Отложено» (Task 12, Ruling 3/8; proj_svc.check_reminders из
// _storage_loop main.py L49): CheckDueRemindersAsync помечает due-строки hold-карточек fired и
// возвращает их {id,title,containerId}. Сбой проверки НЕ роняет тик тенанта/проход: лог-предупреждение,
// остальные тенанты обрабатываются (паттерн ветки AdminTickOrchestrator). SSE reminder_due по каждой
// записи — в канал тенанта (Ruling 8: публикации только из Api; toast НЕ шлём, без подписчиков — no-op).
IReadOnlyList<CardReminderDueDto> dueReminders;
try
{
CardsService reminders = tenantScope.ServiceProvider.GetRequiredService<CardsService>();
dueReminders = await reminders.CheckDueRemindersAsync(ct);
}
catch (OperationCanceledException)
{
// Остановка хоста — прерываем проход штатно (не «сбой проверки напоминаний»).
throw;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Цикл правил хранения: проверка напоминаний тенанта {TenantId} не удалась", tenant.Id);
dueReminders = Array.Empty<CardReminderDueDto>();
}
foreach (CardReminderDueDto due in dueReminders)
{
_broker.Publish(tenant.Id, ReminderDueEventType, due);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Цикл правил хранения: тик тенанта {TenantId} не удался", tenant.Id);
}
finally
{
tenantContext.Reset();
}
}
}
@@ -0,0 +1,94 @@
using Deal.Infrastructure.Tenancy;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Hosting;
/// <summary>
/// Bootstrap при старте (Ruling 8): дефолтный тенант + admin (dev-only), провижининг схем всех тенантов.
/// </summary>
/// <remarks>
/// Идемпотентен. Dev-seed дефолтного тенанта (фиксированный id) и пользователя admin из env
/// DEAL_BOOTSTRAP_LOGIN/DEAL_BOOTSTRAP_PASSWORD (по умолчанию admin/admin) выполняется только в Development
/// или при DEAL_BOOTSTRAP_DEFAULT_TENANT=1 (Ruling 1 этапа 7); в Production тенантов заводит оператор.
/// Провижининг схем ВСЕХ тенантов реестра выполняется всегда. Работает только через порты модулей и
/// IPasswordHasher — без DealDbContext.
/// </remarks>
public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) : IHostedService
{
// Имя дефолтного тенанта, если реестр пуст (Ruling 8).
private const string DefaultTenantName = "Default";
// Фиксированный id дефолтного тенанта: схема tenant_000...0001 детерминирована (Ruling 8).
private static readonly Guid DefaultTenantId = Guid.Parse("00000000-0000-0000-0000-000000000001");
private const string ActiveStatus = "active";
private const string BootstrapLoginEnvKey = "DEAL_BOOTSTRAP_LOGIN";
private const string BootstrapPasswordEnvKey = "DEAL_BOOTSTRAP_PASSWORD";
private const string DefaultAdminLogin = "admin";
private const string DefaultAdminPassword = "admin";
// Ключ env-флага принудительного dev-seed в не-Development окружениях (Ruling 1 этапа 7).
private const string DefaultTenantBootstrapEnvKey = "DEAL_BOOTSTRAP_DEFAULT_TENANT";
// Значение «включено» env-флага (Ruling 1: =1).
private const string DefaultTenantBootstrapEnabledValue = "1";
/// <inheritdoc />
public async Task StartAsync(CancellationToken ct)
{
await using var scope = scopeFactory.CreateAsyncScope();
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var tenantService = scope.ServiceProvider.GetRequiredService<TenantService>();
var tenantRepository = scope.ServiceProvider.GetRequiredService<ITenantRepository>();
var authStore = scope.ServiceProvider.GetRequiredService<IAuthStore>();
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher>();
var login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin);
var password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword;
var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
// Dev-seed дефолтного тенанта + admin становится dev-only (Ruling 1 этапа 7): создаётся только
// в Development или при DEAL_BOOTSTRAP_DEFAULT_TENANT=1. В Production тенантов заводит оператор.
var seedDefaultTenant = environment.IsDevelopment()
|| configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue;
if (seedDefaultTenant)
{
var tenants = await tenantRepository.ListAsync(ct);
if (tenants.Count == 0)
{
// CreateTenantAsync сам провижинит схему дефолтного тенанта (TenantService → ITenantProvisioner).
await tenantService.CreateTenantAsync(DefaultTenantName, DefaultTenantId, ct);
}
// Идемпотентность: пользователь с таким логином уже есть — ничего не делаем.
var existingUser = await authStore.FindUserByLoginAsync(login, ct);
if (existingUser is null)
{
await authStore.CreateUserAsync(
new StoredUserDto(
Id: Guid.NewGuid(),
Login: login,
TenantId: DefaultTenantId,
Status: ActiveStatus,
PasswordHash: passwordHasher.Hash(password)),
ct);
}
}
// Провижининг схем ВСЕХ тенантов реестра выполняется всегда (Ruling 1 этапа 7), включая только
// что созданного в dev: пакетная идемпотентная миграция (CREATE SCHEMA IF NOT EXISTS + Migrate,
// применяющий только неприменённые миграции) с ограниченным параллелизмом и логированием прогресса —
// масштабируется на сотни/тысячи схем (этап 12, пакет C). Повторный старт безопасен.
var tenantSchemaMigrationService = scope.ServiceProvider.GetRequiredService<TenantSchemaMigrationService>();
await tenantSchemaMigrationService.MigrateAllAsync(ct);
}
/// <inheritdoc />
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
// login: Входной логин.
// Возвращает: Нормализованный логин.
private static string NormalizeLogin(string login) => login.Trim().ToLowerInvariant();
}