Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
+83
View File
@@ -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();
}
+59
View File
@@ -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);
+10
View File
@@ -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);
+60
View File
@@ -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);
}
+139
View File
@@ -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&lt;DateTimeOffset&gt;</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">Разрешённое число запросов в окне (&gt;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;
}
}
}