Добить структуру Api, Contracts, SharedKernel и сервисов
Deal.Api/Http -> Services/Models/Extensions; Contracts/Integrations и SharedKernel/Tenants -> Abstractions/Models; extension-классы telegram/ml -> Extensions. namespace/using/FQN мигрированы, using дедуплицированы.
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
using Deal.Api.Configuration;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
|
||||
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
|
||||
|
||||
@@ -1,154 +1,155 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using Deal.Api.Configuration;
|
||||
using Deal.Api.Http;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрация встроенного rate limiter ASP.NET Core (план Task 11, Ruling 5; этап 12, пакет B — хранилище
|
||||
/// на Postgres) и его политики: "auth" — фиксированное окно на IP клиента (ручки входа /api/auth/login и
|
||||
/// /api/operator/auth/login), "api" — на тенанта из <see cref="CurrentUser"/> либо IP анонима
|
||||
/// (Session/OperatorSessionMiddleware отрабатывают раньше — порядок Session → Operator → RateLimiter).
|
||||
/// API-партиция выставляется и глобальным лимитером: все /api-эндпоинты без собственной политики
|
||||
/// ограничены 600/мин на тенанта/IP. Отказ любого лимитера — HTTP 429 с телом <c>{"detail": "…"}</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется только при <see cref="RateLimitOptions.Enabled"/> (в dev/тестах middleware и политики
|
||||
/// не создаются — Ruling 5). gRPC-ингресс (:5082) HTTP-лимитером освобождён (DisableRateLimiting на
|
||||
/// MapGrpcService): лимит по tenant-id там считает IngressRateLimitInterceptor — иначе входящий поток
|
||||
/// telegram-service резался бы общим окном на IP.
|
||||
/// <para>
|
||||
/// Счётчики окон живут в Postgres (<see cref="IRateLimitCounterStore"/> через scoped
|
||||
/// <see cref="StoreBackedFixedWindowRateLimiter"/>) — лимиты общие для всех инстансов core. Пороги/окна
|
||||
/// не менялись: фиксированные 1 минута, PermitLimit из <see cref="RateLimitOptions"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class RateLimitPolicies
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя политики входа: фиксированное окно по IP (RateLimit:AuthPerMinute).
|
||||
/// </summary>
|
||||
public const string AuthPolicy = "auth";
|
||||
|
||||
/// <summary>
|
||||
/// Имя API-политики: фиксированное окно по CurrentUser.TenantId либо IP анонима (RateLimit:ApiPerMinute).
|
||||
/// </summary>
|
||||
public const string ApiPolicy = "api";
|
||||
|
||||
/// <summary>
|
||||
/// Текст 429 rate limiter (Ruling 5): все отказы лимитов запросов — единый detail.
|
||||
/// </summary>
|
||||
public const string RejectedDetail = "Слишком много запросов. Повторите позже";
|
||||
|
||||
// Префикс ключа хранилища для HTTP-политик: изолирует счётчики от gRPC-ингресса и login-guard.
|
||||
private const string HttpKeyPrefix = "http:";
|
||||
|
||||
// Пространство ключа глобального лимитера: отдельные счётчики от именованной "api"-политики
|
||||
// (как и раньше — независимые FixedWindowRateLimiter'ы, лимиты не схлопываются в один бакет).
|
||||
private const string GlobalScope = "global";
|
||||
|
||||
// Префикс партиции API-политики для анонима (IP не разрешён в сессию).
|
||||
private const string IpKeyPrefix = "ip:";
|
||||
|
||||
// Префикс партиции API-политики для аутентифицированного пользователя (по тенанту).
|
||||
private const string TenantKeyPrefix = "tenant:";
|
||||
|
||||
// Ключ партиции, когда IP клиента недоступен (служебные/тестовые вызовы).
|
||||
private const string UnknownClientKey = "unknown";
|
||||
|
||||
// Длина окна политик — 1 минута (имена настроек — «PerMinute»).
|
||||
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует AddRateLimiter с политиками "auth"/"api" и глобальным лимитером API-партиции.
|
||||
/// </summary>
|
||||
/// <param name="services">Коллекция сервисов хоста.</param>
|
||||
/// <param name="options">Настройки rate limiting (секция RateLimit).</param>
|
||||
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
|
||||
public static IServiceCollection AddDealRateLimiter(this IServiceCollection services, RateLimitOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
int apiPerMinute = options.ApiPerMinute;
|
||||
services.AddRateLimiter(limiter =>
|
||||
{
|
||||
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
limiter.OnRejected = OnRejectedAsync;
|
||||
limiter.AddPolicy(AuthPolicy, context => AuthPartition(context, options.AuthPerMinute));
|
||||
limiter.AddPolicy(ApiPolicy, context => ApiPartition(context, ApiPolicy, apiPerMinute));
|
||||
// Глобальный лимитер — та же API-партиция (своё пространство ключей): эндпоинты без
|
||||
// RequireRateLimiting ограничены по тенанту/IP (весь /api, кроме ручек входа и gRPC).
|
||||
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
|
||||
context => ApiPartition(context, GlobalScope, apiPerMinute));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
// Партиция политики "auth": фиксированное окно в минуту на IP клиента.
|
||||
// context: Контекст запроса (адрес клиента).
|
||||
// permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:AuthPerMinute).
|
||||
// Возвращает: Партиция лимитера с ключом IP.
|
||||
private static RateLimitPartition<string> AuthPartition(HttpContext context, int permitsPerMinute)
|
||||
=> StorePartition(context, AuthPolicy, ClientKey(context), permitsPerMinute);
|
||||
|
||||
// Партиция политики "api": ключ — тенант сессии (CurrentUser) либо IP анонима.
|
||||
// context: Контекст запроса (CurrentUser кладёт SessionMiddleware до UseRateLimiter).
|
||||
// scope: Пространство ключей (имя политики или "global" для глобального лимитера).
|
||||
// permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:ApiPerMinute).
|
||||
// Возвращает: Партиция лимитера с ключом tenant:{id} либо IP.
|
||||
private static RateLimitPartition<string> ApiPartition(
|
||||
HttpContext context,
|
||||
string scope,
|
||||
int permitsPerMinute)
|
||||
{
|
||||
string partitionKey = context.GetCurrentUser() is { } user
|
||||
? TenantKeyPrefix + user.TenantId.ToString("N")
|
||||
: ClientKey(context);
|
||||
return StorePartition(context, scope, partitionKey, permitsPerMinute);
|
||||
}
|
||||
|
||||
// Строит партицию на хранилище Postgres: ключ = http:{scope}:{partitionKey}, лимитер — лениво.
|
||||
// context: Контекст запроса (источник IServiceScopeFactory для лениво создаваемого лимитера).
|
||||
// scope: Пространство ключей (политика/global).
|
||||
// partitionKey: Ключ партиции (IP/тенант).
|
||||
// permitsPerMinute: Разрешено запросов в минуту.
|
||||
// Возвращает: Партиция store-backed лимитера (ленивое создание и кеш — менеджер партиций).
|
||||
private static RateLimitPartition<string> StorePartition(
|
||||
HttpContext context,
|
||||
string scope,
|
||||
string partitionKey,
|
||||
int permitsPerMinute)
|
||||
{
|
||||
string storeKey = HttpKeyPrefix + scope + ":" + partitionKey;
|
||||
return RateLimitPartition.Get(storeKey, key => new StoreBackedFixedWindowRateLimiter(
|
||||
context.RequestServices.GetRequiredService<IServiceScopeFactory>(),
|
||||
key,
|
||||
permitsPerMinute,
|
||||
Window,
|
||||
() => DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
// Ключ клиента: строка IP (или unknown, если адрес недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Ключ партиции по IP.
|
||||
private static string ClientKey(HttpContext context)
|
||||
{
|
||||
string? ip = context.Connection.RemoteIpAddress?.ToString();
|
||||
return string.IsNullOrEmpty(ip) ? UnknownClientKey : IpKeyPrefix + ip;
|
||||
}
|
||||
|
||||
// Пишет ответ 429 формата прототипа: {"detail": "…"} (Ruling 10/5).
|
||||
// context: Контекст отклонённого запроса.
|
||||
// cancellationToken: Токен отмены ответа.
|
||||
private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken);
|
||||
}
|
||||
}
|
||||
using System.Threading.RateLimiting;
|
||||
using Deal.Api.Configuration;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрация встроенного rate limiter ASP.NET Core (план Task 11, Ruling 5; этап 12, пакет B — хранилище
|
||||
/// на Postgres) и его политики: "auth" — фиксированное окно на IP клиента (ручки входа /api/auth/login и
|
||||
/// /api/operator/auth/login), "api" — на тенанта из <see cref="CurrentUser"/> либо IP анонима
|
||||
/// (Session/OperatorSessionMiddleware отрабатывают раньше — порядок Session → Operator → RateLimiter).
|
||||
/// API-партиция выставляется и глобальным лимитером: все /api-эндпоинты без собственной политики
|
||||
/// ограничены 600/мин на тенанта/IP. Отказ любого лимитера — HTTP 429 с телом <c>{"detail": "…"}</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется только при <see cref="RateLimitOptions.Enabled"/> (в dev/тестах middleware и политики
|
||||
/// не создаются — Ruling 5). gRPC-ингресс (:5082) HTTP-лимитером освобождён (DisableRateLimiting на
|
||||
/// MapGrpcService): лимит по tenant-id там считает IngressRateLimitInterceptor — иначе входящий поток
|
||||
/// telegram-service резался бы общим окном на IP.
|
||||
/// <para>
|
||||
/// Счётчики окон живут в Postgres (<see cref="IRateLimitCounterStore"/> через scoped
|
||||
/// <see cref="StoreBackedFixedWindowRateLimiter"/>) — лимиты общие для всех инстансов core. Пороги/окна
|
||||
/// не менялись: фиксированные 1 минута, PermitLimit из <see cref="RateLimitOptions"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class RateLimitPolicies
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя политики входа: фиксированное окно по IP (RateLimit:AuthPerMinute).
|
||||
/// </summary>
|
||||
public const string AuthPolicy = "auth";
|
||||
|
||||
/// <summary>
|
||||
/// Имя API-политики: фиксированное окно по CurrentUser.TenantId либо IP анонима (RateLimit:ApiPerMinute).
|
||||
/// </summary>
|
||||
public const string ApiPolicy = "api";
|
||||
|
||||
/// <summary>
|
||||
/// Текст 429 rate limiter (Ruling 5): все отказы лимитов запросов — единый detail.
|
||||
/// </summary>
|
||||
public const string RejectedDetail = "Слишком много запросов. Повторите позже";
|
||||
|
||||
// Префикс ключа хранилища для HTTP-политик: изолирует счётчики от gRPC-ингресса и login-guard.
|
||||
private const string HttpKeyPrefix = "http:";
|
||||
|
||||
// Пространство ключа глобального лимитера: отдельные счётчики от именованной "api"-политики
|
||||
// (как и раньше — независимые FixedWindowRateLimiter'ы, лимиты не схлопываются в один бакет).
|
||||
private const string GlobalScope = "global";
|
||||
|
||||
// Префикс партиции API-политики для анонима (IP не разрешён в сессию).
|
||||
private const string IpKeyPrefix = "ip:";
|
||||
|
||||
// Префикс партиции API-политики для аутентифицированного пользователя (по тенанту).
|
||||
private const string TenantKeyPrefix = "tenant:";
|
||||
|
||||
// Ключ партиции, когда IP клиента недоступен (служебные/тестовые вызовы).
|
||||
private const string UnknownClientKey = "unknown";
|
||||
|
||||
// Длина окна политик — 1 минута (имена настроек — «PerMinute»).
|
||||
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует AddRateLimiter с политиками "auth"/"api" и глобальным лимитером API-партиции.
|
||||
/// </summary>
|
||||
/// <param name="services">Коллекция сервисов хоста.</param>
|
||||
/// <param name="options">Настройки rate limiting (секция RateLimit).</param>
|
||||
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
|
||||
public static IServiceCollection AddDealRateLimiter(this IServiceCollection services, RateLimitOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
int apiPerMinute = options.ApiPerMinute;
|
||||
services.AddRateLimiter(limiter =>
|
||||
{
|
||||
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
limiter.OnRejected = OnRejectedAsync;
|
||||
limiter.AddPolicy(AuthPolicy, context => AuthPartition(context, options.AuthPerMinute));
|
||||
limiter.AddPolicy(ApiPolicy, context => ApiPartition(context, ApiPolicy, apiPerMinute));
|
||||
// Глобальный лимитер — та же API-партиция (своё пространство ключей): эндпоинты без
|
||||
// RequireRateLimiting ограничены по тенанту/IP (весь /api, кроме ручек входа и gRPC).
|
||||
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
|
||||
context => ApiPartition(context, GlobalScope, apiPerMinute));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
// Партиция политики "auth": фиксированное окно в минуту на IP клиента.
|
||||
// context: Контекст запроса (адрес клиента).
|
||||
// permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:AuthPerMinute).
|
||||
// Возвращает: Партиция лимитера с ключом IP.
|
||||
private static RateLimitPartition<string> AuthPartition(HttpContext context, int permitsPerMinute)
|
||||
=> StorePartition(context, AuthPolicy, ClientKey(context), permitsPerMinute);
|
||||
|
||||
// Партиция политики "api": ключ — тенант сессии (CurrentUser) либо IP анонима.
|
||||
// context: Контекст запроса (CurrentUser кладёт SessionMiddleware до UseRateLimiter).
|
||||
// scope: Пространство ключей (имя политики или "global" для глобального лимитера).
|
||||
// permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:ApiPerMinute).
|
||||
// Возвращает: Партиция лимитера с ключом tenant:{id} либо IP.
|
||||
private static RateLimitPartition<string> ApiPartition(
|
||||
HttpContext context,
|
||||
string scope,
|
||||
int permitsPerMinute)
|
||||
{
|
||||
string partitionKey = context.GetCurrentUser() is { } user
|
||||
? TenantKeyPrefix + user.TenantId.ToString("N")
|
||||
: ClientKey(context);
|
||||
return StorePartition(context, scope, partitionKey, permitsPerMinute);
|
||||
}
|
||||
|
||||
// Строит партицию на хранилище Postgres: ключ = http:{scope}:{partitionKey}, лимитер — лениво.
|
||||
// context: Контекст запроса (источник IServiceScopeFactory для лениво создаваемого лимитера).
|
||||
// scope: Пространство ключей (политика/global).
|
||||
// partitionKey: Ключ партиции (IP/тенант).
|
||||
// permitsPerMinute: Разрешено запросов в минуту.
|
||||
// Возвращает: Партиция store-backed лимитера (ленивое создание и кеш — менеджер партиций).
|
||||
private static RateLimitPartition<string> StorePartition(
|
||||
HttpContext context,
|
||||
string scope,
|
||||
string partitionKey,
|
||||
int permitsPerMinute)
|
||||
{
|
||||
string storeKey = HttpKeyPrefix + scope + ":" + partitionKey;
|
||||
return RateLimitPartition.Get(storeKey, key => new StoreBackedFixedWindowRateLimiter(
|
||||
context.RequestServices.GetRequiredService<IServiceScopeFactory>(),
|
||||
key,
|
||||
permitsPerMinute,
|
||||
Window,
|
||||
() => DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
// Ключ клиента: строка IP (или unknown, если адрес недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Ключ партиции по IP.
|
||||
private static string ClientKey(HttpContext context)
|
||||
{
|
||||
string? ip = context.Connection.RemoteIpAddress?.ToString();
|
||||
return string.IsNullOrEmpty(ip) ? UnknownClientKey : IpKeyPrefix + ip;
|
||||
}
|
||||
|
||||
// Пишет ответ 429 формата прототипа: {"detail": "…"} (Ruling 10/5).
|
||||
// context: Контекст отклонённого запроса.
|
||||
// cancellationToken: Токен отмены ответа.
|
||||
private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using Deal.Api.Configuration;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Deal.SharedKernel.Tenants;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
|
||||
using CookieOptions = Deal.Api.Configuration.CookieOptions;
|
||||
|
||||
Reference in New Issue
Block a user