Добить структуру 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:
Rustam Khalimov
2026-09-11 13:25:18 +03:00
parent 492950bdd0
commit e3a2692507
191 changed files with 1539 additions and 1350 deletions
@@ -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;
+155 -154
View File
@@ -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;