Files
Deal/src/core/Deal.Modules.Discovery/Application/DiscoveryBanGuard.cs
T
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00

120 lines
7.6 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.Text.Json;
using Deal.Modules.Settings.Application;
namespace Deal.Modules.Discovery.Application;
/// <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;
}