Разбить модули Deal.Modules.* по назначению

Application проектов Discovery, Kanban, Pipeline, Settings, Tenants
разделён на Abstractions/Exceptions/Extensions/Models/Registrars/Services;
namespace приведён к путям, using потребителей мигрированы и
дедуплицированы (169 файлов), cref/FQN обновлены.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:18:14 +03:00
parent 31c434ed93
commit cd0b3b606b
356 changed files with 14436 additions and 12906 deletions
@@ -0,0 +1,127 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Registrars;
namespace Deal.Modules.Discovery.Application.Services;
/// <summary>
/// Бан-гард авто-вступлений Discovery: суточный лимит, flood-день, стоп-кран (1:1 ban_guard.py L181, план Task 18, Ruling 10).
/// </summary>
/// <remarks>
/// Суточный лимит (discJoinLimit, дефолт 50) считается по DiscLog: число событий event='join_auto' за текущие
/// UTC-сутки (дискретность совпадает с логом mark_joined авто-вступления — дискретный счётчик 1:1 с python
/// L2935). При FloodWait от Telegram воркер зовёт <see cref="NoteFloodAsync"/> — блокировка авто-вступлений до
/// конца суток (внутренний ключ SettingsKeys.DiscFloodDay, epoch-ms начала суток). Стоп-кран discPaused —
/// ручная пауза вступлений. <see cref="CanAutoJoinAsync"/> — общий вердикт «разрешено ли следующее авто-
/// вступление». Настройки читаются типизированным снимком TenantSettingsSnapshot (C30): отсутствие
/// строки/повреждение → SettingsDefaults.
/// <para>
/// Паузу между вступлениями (discJoinDelayMin..Max, 5070 с) гард НЕ держит — её делает <see cref="IDiscoveryPacer"/>
/// (рандом недоступен тестам; 1:1 ban_guard.wait_join_delay L4456).
/// </para>
/// </remarks>
public sealed class DiscoveryBanGuard
{
private readonly IDiscoveryStore _store;
private readonly ISettingsStore _settings;
private readonly Func<DateTimeOffset> _utcNow;
/// <summary>
/// Создаёт бан-гард.
/// </summary>
/// <param name="store">Хранилище Discovery (счётчик DiscLog за сутки).</param>
/// <param name="settings">KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused).</param>
/// <param name="utcNow">Источник текущего времени (UTC; тесты передают фиксированные «часы», эталон
/// FakeDiscoveryStore). По умолчанию — <see cref="DateTimeOffset.UtcNow"/>.</param>
public DiscoveryBanGuard(IDiscoveryStore store, ISettingsStore settings, Func<DateTimeOffset>? utcNow = null)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(settings);
_store = store;
_settings = settings;
_utcNow = utcNow ?? (() => DateTimeOffset.UtcNow);
}
/// <summary>
/// Ручной стоп-кран авто-вступлений (setting discPaused; ban_guard.global_paused L6971).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — вступления на паузе (воркер не делает сетевых шагов).</returns>
public async Task<bool> GlobalPausedAsync(CancellationToken ct)
{
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct);
return settingsSnapshot.GetBool(SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused);
}
/// <summary>
/// Авто-вступления за текущие UTC-сутки (ban_guard.joins_today_auto L2935).
/// </summary>
/// <remarks>События DiscLog event='join_auto' с CreatedAt ≥ начала текущих UTC-суток (все тенанты держат
/// времена UTC; счётчик учитывает и строки ручных вступлений с auto=false? — нет: фильтр по событию join_auto).</remarks>
/// <param name="ct">Токен отмены.</param>
/// <returns>Число авто-вступлений сегодня (0 — вступлений нет).</returns>
public Task<int> JoinsTodayAutoAsync(CancellationToken ct)
=> _store.CountLogEventAsync(DiscoveryLogEvents.JoinAuto, StartOfDayUtc(), ct);
/// <summary>
/// Была ли flood-блокировка в текущие UTC-сутки (ban_guard.flood_today L6466).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — <see cref="NoteFloodAsync"/> фиксировалась сегодня (стоп до конца суток).</returns>
public async Task<bool> FloodTodayAsync(CancellationToken ct)
{
TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct);
return IsFloodToday(snapshot.GetLong(SettingsKeys.DiscFloodDay, 0), StartOfDayMs());
}
/// <summary>
/// Разрешено ли авто-вступление: лимит не исчерпан, нет flood на сегодня, нет стоп-крана
/// (ban_guard.can_auto_join L3841).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — следующее авто-вступление допустимо.</returns>
public async Task<bool> CanAutoJoinAsync(CancellationToken ct)
{
// Один типизированный снимок настроек на вердикт (C30): discJoinLimit/discFloodDay/discPaused
// читаются одним GetAllAsync вместо трёх GetAsync.
TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct);
int limit = snapshot.GetInt(SettingsKeys.DiscJoinLimit, SettingsDefaults.DiscJoinLimit);
int joined = await JoinsTodayAutoAsync(ct).ConfigureAwait(false);
bool floodToday = IsFloodToday(snapshot.GetLong(SettingsKeys.DiscFloodDay, 0), StartOfDayMs());
bool paused = snapshot.GetBool(SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused);
return joined < Math.Max(1, limit) && !floodToday && !paused;
}
/// <summary>
/// Фиксирует FloodWait: блокировка авто-вступлений до конца суток (ban_guard.note_flood L5962).
/// </summary>
/// <remarks>Запись идемпотентна в течение суток: DiscFloodDay = epoch-ms начала текущих UTC-суток
/// (повторный note_flood в тот же день перезаписывает тем же значением).</remarks>
/// <param name="ct">Токен отмены.</param>
public Task NoteFloodAsync(CancellationToken ct)
=> _settings.SetAsync(SettingsKeys.DiscFloodDay, JsonSerializer.Serialize(StartOfDayMs()), ct);
// Начало текущих UTC-суток (python _start_of_day_ms L2326).
private DateTimeOffset StartOfDayUtc()
{
DateTimeOffset now = _utcNow().ToUniversalTime();
return new DateTimeOffset(now.Year, now.Month, now.Day, 0, 0, 0, TimeSpan.Zero);
}
// Начало текущих UTC-суток в epoch-ms (python L25).
private long StartOfDayMs() => StartOfDayUtc().ToUnixTimeMilliseconds();
// Flood-день совпал с текущими сутками (ban_guard.flood_today L6466: 0 — блокировок не было).
// floodDay: Epoch-ms начала суток flood-блокировки (0 — не было).
// startOfDayMs: Начало текущих UTC-суток в epoch-ms.
// Возвращает: True — flood фиксировался сегодня.
private static bool IsFloodToday(long floodDay, long startOfDayMs)
=> floodDay != 0 && floodDay == startOfDayMs;
}