Files
Deal/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs
T
Rustam Khalimov 492950bdd0 Отформатировать списки параметров по код-стайлу
Больше двух параметров — каждый на отдельной строке (закрывающая
скобка в конце последнего); два и меньше — в одну строку. Правило
добавлено в docs/spec/Код-стайл-Дейл.md; применено к 628 сигнатурам
в 253 файлах.
2026-09-11 13:22:56 +03:00

131 lines
8.0 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.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;
}