Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Deal.Api.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Хелпер записи аудита действий пользователей тенанта и операторов (единая точка — <see cref="AuditService"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Актор берётся из разрешённой сессии (<c>HttpContext.Items</c>, наполняют SessionMiddleware/
|
||||
/// OperatorSessionMiddleware): для тенанта — <see cref="AuditActorTypes.Tenant"/> с userId/tenantId, для
|
||||
/// оператора — <see cref="AuditActorTypes.Operator"/> без tenantId. IP — адрес клиента без порта.
|
||||
/// Детали — минимальные, без секретов (пароли/токены/api-ключи). Нет сессии — no-op (вызывать после
|
||||
/// проверки HasUser, но безопасно и без неё). Append-only, как весь аудит.
|
||||
/// </remarks>
|
||||
public static class AuditAppender
|
||||
{
|
||||
/// <summary>
|
||||
/// Пишет событие действия пользователя тенанта (актор tenant).
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса (источник актора и IP).</param>
|
||||
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
|
||||
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public static async Task AppendTenantAsync(HttpContext context, string eventType, object? details, CancellationToken ct)
|
||||
{
|
||||
CurrentUser? user = context.GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AuditService audit = context.RequestServices.GetRequiredService<AuditService>();
|
||||
await audit.AppendAsync(
|
||||
new AuditRecordDto(
|
||||
eventType,
|
||||
AuditActorTypes.Tenant,
|
||||
ActorId: user.UserId,
|
||||
TenantId: user.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: DetailJson(details)),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пишет событие действия оператора (актор operator, без tenantId).
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса (источник актора и IP).</param>
|
||||
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
|
||||
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public static async Task AppendOperatorAsync(HttpContext context, string eventType, object? details, CancellationToken ct)
|
||||
{
|
||||
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AuditService audit = context.RequestServices.GetRequiredService<AuditService>();
|
||||
await audit.AppendAsync(
|
||||
new AuditRecordDto(
|
||||
eventType,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: DetailJson(details)),
|
||||
ct);
|
||||
}
|
||||
|
||||
// Сериализует детали события (null — деталей нет).
|
||||
// details: Объект деталей или null.
|
||||
// Возвращает: JSON деталей (camelCase) или null.
|
||||
private static string? DetailJson(object? details) =>
|
||||
details is null ? null : AuditService.ToDetailJson(details);
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace Deal.Api.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Хелперы доступа к текущему пользователю запроса (минимальные API).
|
||||
/// </summary>
|
||||
public static class AuthHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Ключ в <c>HttpContext.Items</c>, по которому SessionMiddleware хранит CurrentUser.
|
||||
/// </summary>
|
||||
public const string CurrentUserItemKey = "CurrentUser";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ в <c>HttpContext.Items</c>, по которому OperatorSessionMiddleware хранит CurrentOperator.
|
||||
/// </summary>
|
||||
public const string CurrentOperatorItemKey = "CurrentOperator";
|
||||
|
||||
/// <summary>
|
||||
/// Сообщение 401 для эндпоинтов, требующих авторизации (семантика прототипа, Ruling 10).
|
||||
/// </summary>
|
||||
public const string UnauthorizedDetail = "Требуется авторизация";
|
||||
|
||||
/// <summary>
|
||||
/// Сообщение 401 для ручек /api/operator/* без разрешённой операторской сессии (Ruling 1).
|
||||
/// </summary>
|
||||
public const string OperatorUnauthorizedDetail = "Требуется вход оператора";
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт пользователя в <c>HttpContext.Items</c>.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <param name="user">Пользователь, разрешённый по сессии.</param>
|
||||
public static void SetCurrentUser(this HttpContext context, CurrentUser user) =>
|
||||
context.Items[CurrentUserItemKey] = user;
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает текущего пользователя запроса или null, если сессия не разрешена.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <returns>Пользователь или null.</returns>
|
||||
public static CurrentUser? GetCurrentUser(this HttpContext context) =>
|
||||
context.Items[CurrentUserItemKey] as CurrentUser;
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт оператора в <c>HttpContext.Items</c>.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <param name="operatorIdentity">Оператор, разрешённый по операторской сессии.</param>
|
||||
public static void SetCurrentOperator(this HttpContext context, CurrentOperator operatorIdentity) =>
|
||||
context.Items[CurrentOperatorItemKey] = operatorIdentity;
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает текущего оператора запроса или null, если операторская сессия не разрешена.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <returns>Оператор или null (ручка /api/operator/* отвечает 401).</returns>
|
||||
public static CurrentOperator? GetCurrentOperator(this HttpContext context) =>
|
||||
context.Items[CurrentOperatorItemKey] as CurrentOperator;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Deal.Api.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Текущий оператор запроса — кладёт OperatorSessionMiddleware в <c>HttpContext.Items</c> (Ruling 1).
|
||||
/// </summary>
|
||||
/// <param name="OperatorId">Идентификатор оператора.</param>
|
||||
/// <param name="Login">Логин в нижнем регистре.</param>
|
||||
/// <param name="Status">Статус учётной записи (всегда "active" — разрешаются только активные).</param>
|
||||
public sealed record CurrentOperator(Guid OperatorId, string Login, string Status);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Deal.Api.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Текущий пользователь запроса — кладётся SessionMiddleware в <c>HttpContext.Items</c>.
|
||||
/// </summary>
|
||||
/// <param name="UserId">Идентификатор пользователя.</param>
|
||||
/// <param name="Login">Логин в нижнем регистре.</param>
|
||||
/// <param name="TenantId">Идентификатор тенанта, которому принадлежит пользователь.</param>
|
||||
/// <param name="Status">Статус учётной записи.</param>
|
||||
public sealed record CurrentUser(Guid UserId, string Login, Guid TenantId, string Status);
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace Deal.Api.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Общие ответы ошибок минимальных API: HTTP-код + <c>{"detail":"…"}</c> (формат прототипа, Ruling 10).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единая точка для всех Endpoints-файлов (Auth/Settings/Ai-проверка): сообщения деталей —
|
||||
/// фиксированные строки прототипа (FastAPI HTTPException), задаются вызывающим.
|
||||
/// </remarks>
|
||||
public static class EndpointResults
|
||||
{
|
||||
/// <summary>
|
||||
/// 401-ответ: сессия не разрешена/учётные данные не приняты.
|
||||
/// </summary>
|
||||
/// <param name="detail">Текст ошибки (напр. <see cref="AuthHelpers.UnauthorizedDetail"/>).</param>
|
||||
/// <returns>JSON-ответ {detail} со статусом 401.</returns>
|
||||
public static IResult Unauthorized(string detail) =>
|
||||
Results.Json(new { detail }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
|
||||
/// <summary>
|
||||
/// 400-ответ: некорректный запрос (тело/значения).
|
||||
/// </summary>
|
||||
/// <param name="detail">Текст ошибки.</param>
|
||||
/// <returns>JSON-ответ {detail} со статусом 400.</returns>
|
||||
public static IResult BadRequest(string detail) =>
|
||||
Results.Json(new { detail }, statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
/// <summary>
|
||||
/// 404-ответ: ресурс не найден (формат прототипа, Ruling 10).
|
||||
/// </summary>
|
||||
/// <param name="detail">Текст ошибки.</param>
|
||||
/// <returns>JSON-ответ {detail} со статусом 404.</returns>
|
||||
public static IResult NotFound(string detail) =>
|
||||
Results.Json(new { detail }, statusCode: StatusCodes.Status404NotFound);
|
||||
|
||||
/// <summary>
|
||||
/// 403-ответ: запрос понятен, но доступ запрещён (приостановленный тенант, план Task 7/Ruling 10(5)).
|
||||
/// </summary>
|
||||
/// <param name="detail">Текст ошибки.</param>
|
||||
/// <returns>JSON-ответ {detail} со статусом 403.</returns>
|
||||
public static IResult Forbidden(string detail) =>
|
||||
Results.Json(new { detail }, statusCode: StatusCodes.Status403Forbidden);
|
||||
|
||||
/// <summary>
|
||||
/// 429-ответ: превышен лимит запросов/попыток входа (план Task 11, Ruling 5: LoginAttemptGuard).
|
||||
/// </summary>
|
||||
/// <param name="detail">Текст ошибки.</param>
|
||||
/// <returns>JSON-ответ {detail} со статусом 429.</returns>
|
||||
public static IResult TooManyRequests(string detail) =>
|
||||
Results.Json(new { detail }, statusCode: StatusCodes.Status429TooManyRequests);
|
||||
|
||||
/// <summary>
|
||||
/// 410-ответ: ресурс больше недоступен (Ruling 4: файл без objectKey — «Файл не сохранён
|
||||
/// в объектном хранилище»; api-map §1: 410 в списке кодов ошибок прототипа).
|
||||
/// </summary>
|
||||
/// <param name="detail">Текст ошибки.</param>
|
||||
/// <returns>JSON-ответ {detail} со статусом 410.</returns>
|
||||
public static IResult Gone(string detail) =>
|
||||
Results.Json(new { detail }, statusCode: StatusCodes.Status410Gone);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using Deal.Api.Configuration;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
|
||||
namespace Deal.Api.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной guard неудачных попыток входа (план Task 11, Ruling 5; этап 12, пакет B — хранилище на
|
||||
/// Postgres): фиксированное окно по ключу <c>ip|login</c> — после <see cref="RateLimitOptions.LoginAttemptsMax"/>
|
||||
/// неудач в окне <see cref="RateLimitOptions.LoginAttemptWindowMin"/> минут последующие попытки ключа
|
||||
/// отклоняются (429 «Слишком много попыток входа…»), успешный вход сбрасывает счётчик ключа.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Счётчики живут в общем хранилище <see cref="IRateLimitCounterStore"/> (public.rate_limit_counters) —
|
||||
/// блокировка брутфорса действует на всех инстансах core, а не только на принявшем неудачу (ранее —
|
||||
/// память одного процесса). Часы — инъекцией <c>Func<DateTimeOffset></c> (эталон TenantLimitStore):
|
||||
/// unit-тесты окна идут на фиксированном «сейчас». Гвард включается только при
|
||||
/// <see cref="RateLimitOptions.Enabled"/> (dev/тесты — false: curl-приёмки не режутся, Ruling 5);
|
||||
/// вызовы эндпоинтов не проверяют флаг — no-op внутри guard'а.
|
||||
/// </remarks>
|
||||
public sealed class LoginAttemptGuard
|
||||
{
|
||||
/// <summary>
|
||||
/// Текст 429 при блокировке ключа (Ruling 5, фиксированная формулировка «15 минут»).
|
||||
/// </summary>
|
||||
public const string BlockedDetail = "Слишком много попыток входа. Попробуйте через 15 минут";
|
||||
|
||||
// Разделитель ключа ip|login.
|
||||
private const char KeySeparator = '|';
|
||||
|
||||
// Фолбэк IP в ключе, когда адрес клиента недоступен.
|
||||
private const string UnknownIp = "unknown";
|
||||
|
||||
// Префикс ключа хранилища: изолирует счётчики попыток входа от rate-limit-политик/ингресса.
|
||||
private const string KeyPrefix = "login:";
|
||||
|
||||
// Нижняя граница окна в минутах (защита от нулевого окна в конфигурации).
|
||||
private const int MinimumWindowMinutes = 1;
|
||||
|
||||
private readonly bool _enabled;
|
||||
private readonly int _maxFailures;
|
||||
private readonly long _windowTicks;
|
||||
private readonly IRateLimitCounterStore _store;
|
||||
private readonly Func<DateTimeOffset> _clock;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт гвард с часами UTC-«сейчас» (боевые регистрации).
|
||||
/// </summary>
|
||||
/// <param name="options">Настройки rate limiting (секция RateLimit).</param>
|
||||
/// <param name="store">Хранилище счётчиков фиксированного окна (public.rate_limit_counters).</param>
|
||||
public LoginAttemptGuard(RateLimitOptions options, IRateLimitCounterStore store)
|
||||
: this(options, store, () => DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт гвард с инъекцией часов (тесты фиксируют границы окна).
|
||||
/// </summary>
|
||||
/// <param name="options">Настройки rate limiting (секция RateLimit).</param>
|
||||
/// <param name="store">Хранилище счётчиков фиксированного окна (public.rate_limit_counters).</param>
|
||||
/// <param name="clock">Источник «сейчас».</param>
|
||||
public LoginAttemptGuard(RateLimitOptions options, IRateLimitCounterStore store, Func<DateTimeOffset> clock)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
ArgumentNullException.ThrowIfNull(clock);
|
||||
|
||||
_enabled = options.Enabled;
|
||||
_maxFailures = Math.Max(1, options.LoginAttemptsMax);
|
||||
_windowTicks = TimeSpan.FromMinutes(Math.Max(MinimumWindowMinutes, options.LoginAttemptWindowMin)).Ticks;
|
||||
_store = store;
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Заблокирован ли ключ ip|login (неудач в текущем окне ≥ LoginAttemptsMax).
|
||||
/// </summary>
|
||||
/// <param name="ip">IP клиента (null/пустой — фолбэк unknown).</param>
|
||||
/// <param name="login">Нормализованный логин; пустой/пробельный — блокировке не подлежит.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>true — следующий вход ключа отклоняется 429 до проверки учётных данных.</returns>
|
||||
public async Task<bool> IsBlockedAsync(string? ip, string? login, CancellationToken ct)
|
||||
{
|
||||
if (!_enabled || string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int failures = await _store.GetCountAsync(Key(ip, login), CurrentWindowStart(), ct);
|
||||
return failures >= _maxFailures;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Записывает неудачную попытку входа ключа (счётчик текущего окна).
|
||||
/// </summary>
|
||||
/// <param name="ip">IP клиента (null/пустой — фолбэк unknown).</param>
|
||||
/// <param name="login">Нормализованный логин; пустой/пробельный — не записывается.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public async Task RecordFailureAsync(string? ip, string? login, CancellationToken ct)
|
||||
{
|
||||
if (!_enabled || string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DateTimeOffset windowStart = CurrentWindowStart();
|
||||
await _store.IncrementAsync(Key(ip, login), windowStart, windowStart.AddTicks(_windowTicks), 1, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сбрасывает счётчик ключа (успешный вход, Ruling 5).
|
||||
/// </summary>
|
||||
/// <param name="ip">IP клиента (null/пустой — фолбэк unknown).</param>
|
||||
/// <param name="login">Логин успешно вошедшего (нормализованный).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public async Task ResetAsync(string? ip, string? login, CancellationToken ct)
|
||||
{
|
||||
if (!_enabled || string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _store.ResetAsync(Key(ip, login), ct);
|
||||
}
|
||||
|
||||
// Начало текущего фиксированного окна (выровнено по границам — единое для всех инстансов).
|
||||
// Возвращает: Момент начала окна (UTC).
|
||||
private DateTimeOffset CurrentWindowStart()
|
||||
{
|
||||
long ticks = _clock().UtcTicks;
|
||||
return new DateTimeOffset(ticks - (ticks % _windowTicks), TimeSpan.Zero);
|
||||
}
|
||||
|
||||
// Ключ записи хранилища: login:ip|login (пустой IP — unknown).
|
||||
// ip: IP клиента.
|
||||
// login: Логин (не пустой — проверено вызывающим).
|
||||
// Возвращает: Ключ окна.
|
||||
private static string Key(string? ip, string login) =>
|
||||
KeyPrefix + (string.IsNullOrEmpty(ip) ? UnknownIp : ip) + KeySeparator + login;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
|
||||
using CookieOptions = Deal.Api.Configuration.CookieOptions;
|
||||
|
||||
namespace Deal.Api.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Единая запись httpOnly-куки сессии тенант-пользователя (login/change-password/impersonation).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Кука — <c>deal_session</c> (имя из <see cref="CookieOptions.Name"/>): httpOnly, SameSite=Lax, Path=/,
|
||||
/// MaxAge = дни сессии, Secure — из конфигурации (Ruling 6). Вынесено из AuthEndpoints, чтобы
|
||||
/// impersonation выставлял ту же куку тем же способом (иначе браузер оператора не получает tenant-сессию:
|
||||
/// JS не может записать httpOnly-куку).
|
||||
/// </remarks>
|
||||
public static class SessionCookieWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Выставляет httpOnly-куку сессии на текущем ответе.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <param name="options">Настройки куки из конфигурации (секция Cookies).</param>
|
||||
/// <param name="rawToken">Raw-токен сессии.</param>
|
||||
public static void Append(HttpContext context, CookieOptions options, string rawToken)
|
||||
{
|
||||
context.Response.Cookies.Append(
|
||||
options.Name,
|
||||
rawToken,
|
||||
new AspNetCoreCookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = "/",
|
||||
MaxAge = TimeSpan.FromDays(options.Days),
|
||||
Secure = options.Secure,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Deal.Api.Http;
|
||||
|
||||
/// <summary>
|
||||
/// RateLimiter фиксированного окна с состоянием в Postgres (этап 12, пакет B): лимиты переживают
|
||||
/// несколько инстансов core (ранее — память одного процесса, Ruling 5).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Один экземпляр обслуживает одну партицию (ключ) с одним порогом — создаётся лениво фабрикой
|
||||
/// <c>RateLimitPartition.Get</c> в <see cref="Middleware.RateLimitPolicies"/> и кешируется
|
||||
/// <c>PartitionedRateLimiter</c> до истечения <see cref="IdleDuration"/> (партиции не растут бесконечно).
|
||||
/// Счётчик живёт в <see cref="IRateLimitCounterStore"/>: каждый acquire — атомарный инкремент окна;
|
||||
/// при смене выровненного окна счётчик сбрасывается. Для сопоставимости с прежним in-memory-лимитером
|
||||
/// окно выровнено по границам длины окна, разрешено ровно <c>permitLimit</c> запросов.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Хранилище — scoped EF-адаптер, поэтому на каждое приобретение создаётся собственный DI-scope
|
||||
/// через <see cref="IServiceScopeFactory"/>: держать scoped-контекст в кешируемом лимитере нельзя.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class StoreBackedFixedWindowRateLimiter : RateLimiter
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly string _key;
|
||||
private readonly int _permitLimit;
|
||||
private readonly long _windowTicks;
|
||||
private readonly Func<DateTimeOffset> _clock;
|
||||
private readonly TimeSpan _idleDuration;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт лимитер одной партиции.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Фабрика scope: хранилище счётчиков резолвится на каждое приобретение.</param>
|
||||
/// <param name="key">Ключ счётчика в хранилище (уникален для политики и партиции).</param>
|
||||
/// <param name="permitLimit">Разрешённое число запросов в окне (>0).</param>
|
||||
/// <param name="window">Длина фиксированного окна.</param>
|
||||
/// <param name="clock">Источник «сейчас» (UTC) — тесты фиксируют границы окна.</param>
|
||||
public StoreBackedFixedWindowRateLimiter(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
string key,
|
||||
int permitLimit,
|
||||
TimeSpan window,
|
||||
Func<DateTimeOffset> clock)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(permitLimit, 1);
|
||||
ArgumentNullException.ThrowIfNull(clock);
|
||||
if (window <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(window), window, "Окно лимитера должно быть положительным.");
|
||||
}
|
||||
|
||||
_scopeFactory = scopeFactory;
|
||||
_key = key;
|
||||
_permitLimit = permitLimit;
|
||||
_windowTicks = window.Ticks;
|
||||
_clock = clock;
|
||||
_idleDuration = window;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Партиция неактивна столько же, сколько её окно: устаревшие лимитеры вытесняются менеджером
|
||||
/// партиций и создаются заново при необходимости (состояние — в Postgres, потери нет).</remarks>
|
||||
public override TimeSpan? IdleDuration => _idleDuration;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override RateLimiterStatistics? GetStatistics() => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override RateLimitLease AttemptAcquireCore(int permitCount)
|
||||
=> AcquireCoreAsync(permitCount, CancellationToken.None).AsTask().GetAwaiter().GetResult();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<RateLimitLease> AcquireAsyncCore(int permitCount, CancellationToken cancellationToken)
|
||||
=> await AcquireCoreAsync(permitCount, cancellationToken);
|
||||
|
||||
// Проверяет/потребляет лимит окна: инкремент в хранилище, разрешение — пока счётчик ≤ порога.
|
||||
// permitCount: Сколько единиц запрашивается (0 — только проверка исчерпания).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Лиз: acquired — запрос разрешён.
|
||||
private async ValueTask<RateLimitLease> AcquireCoreAsync(int permitCount, CancellationToken ct)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(permitCount);
|
||||
|
||||
DateTimeOffset windowStart = AlignWindow(_clock());
|
||||
await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope();
|
||||
IRateLimitCounterStore store = scope.ServiceProvider.GetRequiredService<IRateLimitCounterStore>();
|
||||
if (permitCount == 0)
|
||||
{
|
||||
// Проверка без потребления (контракт RateLimiter.AttemptAcquire(0)).
|
||||
int current = await store.GetCountAsync(_key, windowStart, ct);
|
||||
return new FixedWindowLease(current < _permitLimit);
|
||||
}
|
||||
|
||||
DateTimeOffset windowEnd = windowStart.AddTicks(_windowTicks);
|
||||
int count = await store.IncrementAsync(_key, windowStart, windowEnd, permitCount, ct);
|
||||
return new FixedWindowLease(count <= _permitLimit);
|
||||
}
|
||||
|
||||
// Выравнивает момент по границам окна (одинаковые окна у всех инстансов и ключей).
|
||||
// now: Текущий момент.
|
||||
// Возвращает: Начало окна, содержащего now.
|
||||
private DateTimeOffset AlignWindow(DateTimeOffset now)
|
||||
=> new(now.UtcTicks - (now.UtcTicks % _windowTicks), TimeSpan.Zero);
|
||||
|
||||
// Лиз фиксированного окна: разрешён либо нет; метаданных (Retry-After и пр.) не несёт.
|
||||
private sealed class FixedWindowLease(bool acquired) : RateLimitLease
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override bool IsAcquired => acquired;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IEnumerable<string> MetadataNames => [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool TryGetMetadata(string metadataName, out object? metadata)
|
||||
{
|
||||
metadata = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user