Почистить комментарии от упоминаний процесса

Удалены <remarks>, <summary> сжаты до короткой фразы, вырезаны
ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками
на процесс удалены; то же в .proto. Правила обновлены в
docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:39:39 +03:00
parent 5f5538d33b
commit b053d58335
902 changed files with 3902 additions and 12074 deletions
@@ -4,34 +4,28 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис операторской аналитики (этап 10, T3): сводка, агрегаты токенов и лента действий.
/// Прикладной сервис операторской аналитики
/// </summary>
/// <remarks>
/// Read-only: ничего не пишет. Источники — реестр тенантов (<see cref="ITenantRepository"/>), аудит
/// (<see cref="AuditService"/>) и история расхода токенов (<see cref="TokenUsageEventService"/>). Все ручки —
/// под операторской сессией (HTTP-слой); периоды задаются границами from/to (включительно).
/// </remarks>
public sealed class AnalyticsService(
ITenantRepository tenants,
AuditService audit,
TokenUsageEventService tokenUsage)
{
/// <summary>
/// Размер страницы ленты действий по умолчанию (как аудит-лента оператора).
/// Размер страницы ленты действий по умолчанию
/// </summary>
public const int DefaultActivityLimit = AuditService.DefaultQueryLimit;
/// <summary>
/// Верхняя граница размера страницы ленты действий (как аудит-лента оператора).
/// Верхняя граница размера страницы ленты действий
/// </summary>
public const int MaxActivityLimit = AuditService.MaxQueryLimit;
/// <summary>
/// Сводка: тенанты (всего/активных), расход токенов, события, входы/выходы/неудачные входы за период.
/// Сводка: тенанты
/// </summary>
/// <param name="from">Начало периода (включительно; null — без границы).</param>
/// <param name="to">Конец периода (включительно; null — без границы).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сводка аналитики.</returns>
public async Task<AnalyticsOverviewDto> OverviewAsync(
DateTimeOffset? from,
@@ -74,13 +68,12 @@ public sealed class AnalyticsService(
}
/// <summary>
/// Агрегаты расхода токенов по группировке и фильтрам (серия/витрина).
/// Агрегаты расхода токенов по группировке и фильтрам
/// </summary>
/// <param name="groupBy">Группировка day|tenant|provider|model (валидирует HTTP-слой).</param>
/// <param name="tenantId">Тенант (равенство; null — все тенанты).</param>
/// <param name="from">Начало периода (включительно; null — без границы).</param>
/// <param name="to">Конец периода (включительно; null — без границы).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Строки агрегатов и итог.</returns>
public async Task<AnalyticsTokensDto> TokensAsync(
string groupBy,
@@ -101,7 +94,7 @@ public sealed class AnalyticsService(
}
/// <summary>
/// Лента действий (аудит) с фильтрами и пагинацией offset/limit.
/// Лента действий
/// </summary>
/// <param name="eventType">Тип события (равенство; null — без фильтра).</param>
/// <param name="actorType">Тип актора operator|tenant|system (равенство; null — без фильтра).</param>
@@ -111,7 +104,6 @@ public sealed class AnalyticsService(
/// <param name="to">Верхняя граница At (включительно; null — без границы).</param>
/// <param name="limit">Размер страницы (дефолт 100, кламп 1..500).</param>
/// <param name="offset">Смещение страницы (≥0).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Страница записей и полное число по фильтру.</returns>
public async Task<AnalyticsActivityDto> ActivityAsync(
string? eventType,
@@ -135,7 +127,7 @@ public sealed class AnalyticsService(
}
/// <summary>
/// Нормализует limit ленты действий: дефолт <see cref="DefaultActivityLimit"/>, кламп 1..<see cref="MaxActivityLimit"/>.
/// Нормализует limit ленты действий
/// </summary>
/// <param name="limit">Запрошенный размер (null — не задан).</param>
/// <returns>Значение для фильтра.</returns>
@@ -6,24 +6,17 @@ using Deal.SharedKernel.Observability;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис аудита (Ruling 4 этапа 7): append-only запись событий и чтение ленты оператором.
/// Прикладной сервис аудита
/// </summary>
/// <remarks>
/// Единственная точка записи в public.audit_log: <see cref="AppendAsync"/> сам проставляет At=UTC-now, вызывается
/// из эндпоинтов/сервисов (входы, инвайты, impersonation, действия оператора — задачи 410). Update/Delete в
/// порту отсутствуют (append-only на уровне кода); TTL/авто-очистка не делаются (Ruling 4). Чтение — только
/// оператору (GET /api/operator/audit через QueryAsync/CountAsync). Каталог событий — <see cref="AuditEvents"/>,
/// типы акторов — <see cref="AuditActorTypes"/>, JSON деталей — <see cref="ToDetailJson"/> (camelCase, без секретов).
/// </remarks>
public sealed class AuditService(IAuditLogStore store)
{
/// <summary>
/// Верхняя граница выборки аудита (Ruling 4: limit ≤500).
/// Верхняя граница выборки аудита.
/// </summary>
public const int MaxQueryLimit = 500;
/// <summary>
/// Размер выборки по умолчанию при отсутствии limit в запросе (эталон DiscoveryLogService).
/// Размер выборки по умолчанию при отсутствии limit в запросе
/// </summary>
public const int DefaultQueryLimit = 100;
@@ -31,44 +24,40 @@ public sealed class AuditService(IAuditLogStore store)
private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Записывает событие аудита (append-only; At = сейчас, UTC).
/// Записывает событие аудита
/// </summary>
/// <param name="record">Запись события (At и Id игнорируются: At проставляет сервис, Id — БД).</param>
/// <param name="ct">Токен отмены.</param>
public async Task AppendAsync(AuditRecordDto record, CancellationToken ct)
{
await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct);
// Прикладная метрика (этап 12, пакет A): счётчик событий аудита по типу/актору
// (низкокардинальные метки — без tenantId/actorId).
DealMetrics.RecordAuditEvent(record.EventType, record.ActorType);
}
/// <summary>
/// Записи по фильтру, новые сверху (прокси порта; чтение — операторский эндпоинт).
/// Записи по фильтру, новые сверху
/// </summary>
/// <param name="filter">Фильтр выборки.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Записи от новых к старым.</returns>
public Task<IReadOnlyList<AuditRecordDto>> QueryAsync(AuditQueryDto filter, CancellationToken ct) =>
store.QueryAsync(filter, ct);
/// <summary>
/// Число записей по фильтру (для ответа {items, total}).
/// Число записей по фильтру
/// </summary>
/// <param name="filter">Фильтр выборки.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Полное число записей по фильтру.</returns>
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct);
/// <summary>
/// Сериализует детали события в JSON (camelCase; секреты в объект не класть — правило Ruling 4).
/// Сериализует детали события в JSON.
/// </summary>
/// <param name="details">Объект деталей (обычно анонимный: { login = ... }).</param>
/// <param name="details">Объект деталей (обычно анонимный: { login =... }).</param>
/// <returns>JSON-строка деталей.</returns>
public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions);
/// <summary>
/// Актор «пользователь тенанта» по разрешённой сессии: (ActorType, ActorId, TenantId).
/// Актор «пользователь тенанта» по разрешённой сессии
/// </summary>
/// <param name="user">Идентичность пользователя тенанта.</param>
/// <returns>Кортеж актора для полей записи аудита.</returns>
@@ -76,7 +65,7 @@ public sealed class AuditService(IAuditLogStore store)
(AuditActorTypes.Tenant, user.Id, user.TenantId);
/// <summary>
/// Актор «оператор» по разрешённой операторской сессии: (ActorType, ActorId, TenantId=null).
/// Актор «оператор» по разрешённой операторской сессии
/// </summary>
/// <param name="operatorIdentity">Идентичность оператора.</param>
/// <returns>Кортеж актора для полей записи аудита.</returns>
@@ -4,36 +4,20 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис аутентификации: login, logout, смена пароля, разрешение сессии, impersonation.
/// Прикладной сервис аутентификации
/// </summary>
/// <remarks>
/// Семантика повторяет прототип LeadRadar (<c>backend/app/auth.py</c>): сообщения об ошибках
/// фиксирует HTTP-слой (Task 4/7), сервис возвращает коды/null и не бросает исключений
/// для бизнес-отказов. Логин нормализуется в нижний регистр (Ruling: сравнение по lowercase).
/// <para>
/// Гейт приостановки (Task 7/Ruling 10(5); этап 12, пакет B): login проверяет статус тенанта через
/// <see cref="ITenantRepository"/> — suspended возвращает <see cref="LoginResultDto.ErrorTenantSuspended"/>
/// (HTTP-слой отвечает 403 и пишет tenant_login_failed с tenantId); <see cref="ResolveSessionAsync"/>
/// проверяет статус на каждом запросе — активные сессии suspended-тенанта перестают действовать немедленно
/// (включая impersonation), при resume — вновь работают. Impersonation выпускает
/// обычную tenant-сессию выбранного пользователя с маркером оператора (<see cref="SessionDto.ImpersonatedByOperatorId"/>);
/// завершение — logout'ом пользователя, о нём сервис сообщает <see cref="LogoutResultDto"/> для аудита
/// impersonation_stopped. Пароль при impersonation не меняется.
/// </para>
/// </remarks>
public sealed class AuthService(
IAuthStore authStore,
IPasswordHasher passwordHasher,
ITenantRepository tenantRepository)
{
/// <summary>
/// Срок жизни сессии, дней (Ruling 6: 30). Единый источник «30» — на него ссылается кука (Task 5).
/// Срок жизни сессии, дней.
/// </summary>
public const int SessionLifetimeDays = 30;
/// <summary>
/// Минимальная длина нового пароля. Единый источник — на него ссылается активация инвайта
/// (JoinService, Task 6), чтобы минимум не разошёлся.
/// Минимальная длина нового пароля.
/// </summary>
public const int MinNewPasswordLength = 8;
@@ -42,16 +26,11 @@ public sealed class AuthService(
private const string UserActiveStatus = "active";
/// <summary>
/// Вход: при успехе создаёт сессию и возвращает её raw-токен. Вход suspended-тенанта заблокирован (Ruling 10(5)).
/// Вход: при успехе создаёт сессию и возвращает её raw-токен.
/// </summary>
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
/// <param name="password">Пароль в открытом виде.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>
/// При успехе — Login и Token (UserId/TenantId для аудита). Иначе Login/Token null: Error может отличать
/// заблокированный вход приостановленного тенанта (<see cref="LoginResultDto.ErrorTenantSuspended"/>,
/// UserId/TenantId заполнены) от «неверные учётные данные» (Error null, UserId/TenantId пусты).
/// </returns>
/// <returns>При успехе — Login и Token (UserId/TenantId для аудита). Иначе Login/Token null: Error может отличать заблокированный вход приостановленного тенанта (<see cref="LoginResultDto.ErrorTenantSuspended"/>, UserId/TenantId заполнены) от «неверные учётные данные» (Error null, UserId/TenantId пусты).</returns>
public async Task<LoginResultDto> LoginAsync(
string login,
string password,
@@ -74,7 +53,6 @@ public sealed class AuthService(
var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
{
// Заблокированный вход: HTTP-слой пишет tenant_login_failed с tenantId (замечание ревью Task 4).
return new LoginResultDto(
Login: null,
Token: null,
@@ -88,14 +66,10 @@ public sealed class AuthService(
}
/// <summary>
/// Выход: удаляет сессию по raw-токену (no-op без токена).
/// Выход: удаляет сессию по raw-токену
/// </summary>
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>
/// Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped
/// (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null.
/// </returns>
/// <returns>Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null.</returns>
public async Task<LogoutResultDto?> LogoutAsync(string? rawToken, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(rawToken))
@@ -130,11 +104,7 @@ public sealed class AuthService(
/// <param name="login">Логин пользователя.</param>
/// <param name="oldPassword">Текущий пароль.</param>
/// <param name="NewPassword">Новый пароль (минимум 8 символов).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>
/// При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки:
/// <see cref="ChangePasswordResultDto.ErrorOldPassword"/> или <see cref="ChangePasswordResultDto.ErrorTooShort"/>.
/// </returns>
/// <returns>При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки: <see cref="ChangePasswordResultDto.ErrorOldPassword"/> или <see cref="ChangePasswordResultDto.ErrorTooShort"/>.</returns>
public async Task<ChangePasswordResultDto> ChangePasswordAsync(
string login,
string oldPassword,
@@ -153,7 +123,6 @@ public sealed class AuthService(
return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorTooShort, NewToken: null);
}
// Семантика прототипа (change_password + auth_routes.change): разлогиниваем все старые
// сессии, обновляем хэш и выдаём свежую — её raw-токен вернёт HTTP-слой в куке.
await authStore.DeleteSessionsByUserIdAsync(user.Id, ct);
await authStore.UpdatePasswordHashAsync(user.Id, passwordHasher.Hash(newPassword), ct);
@@ -162,19 +131,11 @@ public sealed class AuthService(
}
/// <summary>
/// Impersonation: tenant-сессия целевого пользователя от имени оператора (план Task 7).
/// Impersonation: tenant-сессия целевого пользователя от имени оператора.
/// </summary>
/// <remarks>
/// Пароль пользователя не меняется и не требуется: сессия выпускается оператором напрямую с маркером
/// <see cref="SessionDto.ImpersonatedByOperatorId"/> (для аудита stopped при logout). login опционален:
/// не задан — берётся первый пользователь тенанта (по CreatedAt); задан — пользователь обязан
/// принадлежать тенанту. Приостановленный тенант не блокирует impersonation (операторский доступ,
/// аудируется; ИИ-расход всё равно заморожен гейтом Task 9).
/// </remarks>
/// <param name="tenantId">Идентификатор тенанта.</param>
/// <param name="targetLogin">Логин пользователя (null/пустой — первый пользователь тенанта).</param>
/// <param name="operatorId">Идентификатор оператора, начинающего impersonation (маркер сессии).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Ok=true — SessionToken (raw-токен для куки deal_session) и срок жизни; иначе код ошибки.</returns>
public async Task<ImpersonationResultDto> ImpersonateAsync(
Guid tenantId,
@@ -215,10 +176,9 @@ public sealed class AuthService(
}
/// <summary>
/// Разрешение сессии по raw-токену: возвращает пользователя или null (нет/протухла).
/// Разрешение сессии по raw-токену
/// </summary>
/// <param name="rawToken">Raw-токен из куки.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Идентичность пользователя или null.</returns>
public async Task<UserIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct)
{
@@ -242,9 +202,7 @@ public sealed class AuthService(
if (user is not null)
{
// Приостановка тенанта действует немедленно (этап 12, пакет B): статус проверяется на
// каждом разрешении сессии, поэтому активные сессии suspended-тенанта перестают работать
// сразу после suspend, а не доживают до expiry (ранее — Ruling 10(5)). Флаг состояния не
// храним: при resume доступ возвращается тем же путём (login не блокирует активные сессии).
// Касается и impersonation-сессий (та же tenant-сессия с маркером оператора).
var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
@@ -4,14 +4,8 @@ using Isopoh.Cryptography.Argon2;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Реализация <see cref="IPasswordHasher"/> на Argon2id (Ruling 5).
/// Реализация <see cref="IPasswordHasher"/> на Argon2id.
/// </summary>
/// <remarks>
/// Используются дефолты пакета Isopoh.Cryptography.Argon2 (версия 2.0.0):
/// соль — 16 случайных байт, t=3, m=65536 (64 MiB), p=1, вариант Argon2id
/// (в библиотеке — Argon2Type.HybridAddressing), длина хэша 32 байта.
/// Класс без состояния — регистрируется как singleton.
/// </remarks>
public sealed class DefaultPasswordHasher : IPasswordHasher
{
/// <inheritdoc />
@@ -4,18 +4,12 @@ using Deal.SharedKernel.Utilities;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Генератор кодов приглашений: случайный url-safe код, 16 символов, без префикса (Ruling 2 этапа 7).
/// Генератор кодов приглашений
/// </summary>
/// <remarks>
/// Ровно 16 символов получаются из 12 случайных байт в Base64Url без padding (12 байт → 16 символов).
/// Код — первичный ключ public.invites и одноразовый «секрет» приглашения (его вводит приглашённый в /api/join,
/// Task 6), поэтому источник — криптостойкий <see cref="System.Security.Cryptography.RandomNumberGenerator"/>
/// (общий генератор <see cref="UrlSafeToken"/>, Security review C36).
/// </remarks>
public static class InviteCodeGenerator
{
/// <summary>
/// Длина кода в символах (Ruling 2: 16).
/// Длина кода в символах.
/// </summary>
public const int CodeLength = 16;
@@ -23,7 +17,7 @@ public static class InviteCodeGenerator
private const int RandomByteCount = 12;
/// <summary>
/// Новый код приглашения: 16 url-safe символов (Base64Url 12 случайных байт, без '+' и '/').
/// Новый код приглашения
/// </summary>
/// <returns>Строка кода длиной <see cref="CodeLength"/> символов.</returns>
public static string NewCode() => UrlSafeToken.New(RandomByteCount);
@@ -6,25 +6,15 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис приглашений (Ruling 2 этапа 7): создание оператором, отзыв, список, чтение по коду.
/// Прикладной сервис приглашений
/// </summary>
/// <remarks>
/// Жизненный цикл статусов: pending → revoked | expired | activated. «expired» хранилище не проставляет само:
/// он вычисляется лениво при чтении/проверке (<see cref="GetByCodeAsync"/>/<see cref="ListAsync"/>) и сохраняется,
/// иначе частичный unique-индекс invites.Email по pending (InviteConfiguration, Task 1) заблокировал бы повторное
/// приглашение на тот же email после истечения. Создание/отзыв — только оператор (вызывается из
/// /api/operator/invites, Task 5); тексты HTTP-ошибок фиксирует слой эндпоинтов — сервис возвращает коды/null и
/// не бросает исключений для бизнес-отказов (паттерн AuthService). Активацию (pending → activated) выполняет
/// /api/join (Task 6): он читает приглашение через <see cref="GetByCodeAsync"/> (валидация статуса/expiry).
/// </remarks>
public sealed partial class InvitesService(IInviteStore inviteStore)
{
/// <summary>
/// Срок действия приглашения, часов (Ruling 2: 72).
/// Срок действия приглашения, часов.
/// </summary>
public const int ExpiryHours = 72;
// Максимальная длина email — совпадает с шириной колонки invites.Email (InviteConfiguration, Task 1).
private const int MaxEmailLength = 200;
// Проверяемый формат: один '@', непустые локальная часть и домен с точкой, без пробелов.
@@ -35,12 +25,11 @@ public sealed partial class InvitesService(IInviteStore inviteStore)
private static partial Regex EmailFormatRegex();
/// <summary>
/// Создаёт приглашение оператором: нормализация/валидация email, антидубль активного, срок +72 часа.
/// Создаёт приглашение оператором
/// </summary>
/// <param name="operatorId">Идентификатор оператора (CreatedById приглашения).</param>
/// <param name="email">Email приглашённого (регистр/пробелы не важны — нормализуется).</param>
/// <param name="tenantId">Целевой тенант; null — при активации будет создан новый тенант (Task 6).</param>
/// <param name="ct">Токен отмены.</param>
/// <param name="tenantId">Целевой тенант; null — при активации будет создан новый тенант.</param>
/// <returns>Ok=true + созданное приглашение, либо код ошибки (текст — HTTP-слой).</returns>
public async Task<InviteCreateResultDto> CreateInviteAsync(
Guid operatorId,
@@ -54,7 +43,6 @@ public sealed partial class InvitesService(IInviteStore inviteStore)
return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorInvalidEmail, Invite: null);
}
// Антидубль «одно активное приглашение на email» (Ruling 2): pending блокирует новое. Если найденное
// pending уже истекло (статус ещё не переведён), сначала помечаем expired — иначе partial unique-индекс
// по pending не пустит новую строку; затем создаём новое приглашение. Отозванный/активированный/
// истёкший email свободен (глобальную уникальность регистрации держит unique-индекс users.Login).
@@ -86,10 +74,9 @@ public sealed partial class InvitesService(IInviteStore inviteStore)
}
/// <summary>
/// Отзывает приглашение: только pending переводится в revoked; иные статусы не трогаем (Ruling 2).
/// Отзывает приглашение
/// </summary>
/// <param name="code">Код приглашения.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Ok=true + отозванное приглашение, либо код ошибки (текст — HTTP-слой).</returns>
public async Task<InviteRevokeResultDto> RevokeAsync(string code, CancellationToken ct)
{
@@ -115,9 +102,8 @@ public sealed partial class InvitesService(IInviteStore inviteStore)
}
/// <summary>
/// Список приглашений для оператора (новые сверху) с ленивой пометкой истёкших (status → expired сохраняется).
/// Список приглашений для оператора
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Приглашения в порядке CreatedAt DESC; протухшие pending приходят со статусом expired.</returns>
public async Task<IReadOnlyList<InviteDto>> ListAsync(CancellationToken ct)
{
@@ -140,10 +126,9 @@ public sealed partial class InvitesService(IInviteStore inviteStore)
}
/// <summary>
/// Читает приглашение по коду, вычисляя статус expired при чтении (проверку кода использует /api/join, Task 6).
/// Читает приглашение по коду, вычисляя статус expired при чтении.
/// </summary>
/// <param name="code">Код приглашения.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Приглашение (протухшее pending — со статусом expired и сохранённым переходом) или null.</returns>
public async Task<InviteDto?> GetByCodeAsync(string code, CancellationToken ct)
{
@@ -158,26 +143,22 @@ public sealed partial class InvitesService(IInviteStore inviteStore)
}
/// <summary>
/// Активирует приглашение CAS-переходом (Task 6): атомарный pending → activated, ActivatedAt = сейчас.
/// Прокси над <see cref="IInviteStore.TryActivateAsync"/> — реальное условие на статус исполняет хранилище
/// (условный UPDATE), сервис лишь фиксирует момент активации. Возвращает false, если к моменту обновления
/// приглашение уже не pending (параллельно отозвано/активировано) — вызывающий (JoinService) перечитает статус.
/// Активирует приглашение CAS-переходом
/// </summary>
/// <param name="code">Код приглашения.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>true, если активация выполнена; false — строка не в статусе pending.</returns>
public Task<bool> TryActivateAsync(string code, CancellationToken ct) =>
inviteStore.TryActivateAsync(code, DateTimeOffset.UtcNow, ct);
/// <summary>
/// Нормализация email: обрезка пробелов и нижний регистр (единая форма хранения/сравнения).
/// Нормализация email
/// </summary>
/// <param name="email">Входной email (может быть null).</param>
/// <returns>Нормализованный email (пустая строка, если вход был пустым).</returns>
public static string NormalizeEmail(string? email) => (email ?? string.Empty).Trim().ToLowerInvariant();
/// <summary>
/// Проверка формата email (санити-уровень): непустой, ≤ <see cref="MaxEmailLength"/>, один '@', домен с точкой, без пробелов.
/// Проверка формата email
/// </summary>
/// <param name="email">Входной email (регистр/пробелы не важны).</param>
/// <returns>true, если email выглядит корректно.</returns>
@@ -4,22 +4,8 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис активации инвайта через публичную ручку POST /api/join (Ruling 2, Task 6 этапа 7):
/// код + email + пароль → пользователь (login=email), при пустом TenantId — новый тенант с провижинингом схемы,
/// инвайт переводится в activated. Кука НЕ ставится — после активации клиент входит обычным /api/auth/login.
/// Прикладной сервис активации инвайта через публичную ручку POST /api/join
/// </summary>
/// <remarks>
/// Порядок операций и CAS-семантика (замечание ревью T5): валидации (код/email/пароль/дубль email) выполняются
/// до записи; затем инвайт резервируется атомарным переходом pending → activated (<see cref="InvitesService.TryActivateAsync"/>
/// → условный UPDATE в хранилище). Резервирование первым гарантирует, что из двух параллельных активаций одного кода
/// (или активации против параллельного отзыва) победит ровно одна, а проигравший не создаст «лишних» пользователя/
/// тенанта. Если CAS не прошёл — статус перечитывается, и возвращается фактическая причина (already used / revoked).
/// После резервирования создаётся тенант (если TenantId инвайта пуст) и пользователь; сбой на этом шаге — серверная
/// ошибка (исключение наружу), инвайт остаётся активированным и оператор видит аномалию в списке/аудите. Сервис не
/// бросает исключений для бизнес-отказов — коды ошибок, тексты на HTTP-слое (паттерн AuthService/InvitesService).
/// Глобальную уникальность email держит unique-индекс users.login; предпроверка здесь закрывает типичный случай
/// «уже зарегистрирован» без побочных эффектов (инвайт остаётся pending для повторного использования).
/// </remarks>
public sealed class JoinService(
InvitesService invitesService,
TenantService tenantService,
@@ -31,13 +17,12 @@ public sealed class JoinService(
private const string UserActiveStatus = "active";
/// <summary>
/// Активирует инвайт: валидация, CAS-резервирование, создание тенанта (при необходимости) и пользователя.
/// Активирует инвайт
/// </summary>
/// <param name="code">Код приглашения (пробелы по краям не важны).</param>
/// <param name="email">Email активирующего; обязан совпасть с email приглашения (регистр/пробелы не важны).</param>
/// <param name="name">Имя тенанта при создании нового (TenantId инвайта пуст); null/пустое — имя = email.</param>
/// <param name="password">Пароль пользователя (открытым текстом; минимум <see cref="AuthService.MinNewPasswordLength"/>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>При успехе — Ok=true, Login (нормализованный email), UserId и TenantId; иначе код ошибки (см. <see cref="JoinResultDto"/>).</returns>
public async Task<JoinResultDto> ActivateAsync(
string? code,
@@ -49,7 +34,6 @@ public sealed class JoinService(
string normalizedEmail = InvitesService.NormalizeEmail(email);
string normalizedCode = code?.Trim() ?? string.Empty;
// 1. Чтение приглашения: GetByCodeAsync сам помечает протухшее pending как expired (ленивый переход, Task 5),
// поэтому вернувшийся pending гарантированно не истёк; отдельная проверка кода не нужна — отвечает статус.
var invite = await invitesService.GetByCodeAsync(normalizedCode, ct);
if (invite is null)
@@ -62,7 +46,6 @@ public sealed class JoinService(
return Failed(ErrorFromStatus(invite.Status));
}
// 2. Email обязан совпасть с приглашением (Ruling 2); пустой/иной email не проходит — инвайт не расходуется.
if (normalizedEmail != invite.Email)
{
return Failed(JoinResultDto.ErrorEmailMismatch);
@@ -74,7 +57,6 @@ public sealed class JoinService(
return Failed(JoinResultDto.ErrorPasswordTooShort);
}
// 4. Глобальная уникальность email (users.login unique, Ruling 2): предпроверка до записи, чтобы занятый
// email не создавал тенанта и не расходовал инвайт (остаётся pending — оператор видит/отзывает его).
var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct);
if (existingUser is not null)
@@ -4,19 +4,12 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис аутентификации оператора (Ruling 1 этапа 7): login, logout, разрешение сессии.
/// Прикладной сервис аутентификации оператора
/// </summary>
/// <remarks>
/// Оператор ≠ пользователь тенанта: учётные записи и сессии живут в отдельных public-таблицах
/// (Operators/OperatorSessions) и за отдельным портом <see cref="IOperatorAuthStore"/>, а кука
/// deal_operator_session (Task 3) не совпадает с тенантной deal_session — взаимной подмены сессий нет.
/// Срок жизни операторской сессии — 12 часов. Семантика повторяет <see cref="AuthService"/>: бизнес-отказы
/// возвращаются кодами/null, тексты сообщений фиксирует HTTP-слой (Task 3).
/// </remarks>
public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
{
/// <summary>
/// Срок жизни сессии оператора, часов (Ruling 1: 12). Единый источник «12» — на него ссылается кука (Task 3).
/// Срок жизни сессии оператора, часов.
/// </summary>
public const int SessionLifetimeHours = 12;
@@ -28,7 +21,6 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP
/// </summary>
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
/// <param name="password">Пароль в открытом виде.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>При успехе — Login и Token; иначе оба null (текст «Неверный логин или пароль оператора» фиксирует endpoint).</returns>
public async Task<OperatorLoginResultDto> LoginAsync(
string login,
@@ -52,10 +44,9 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP
}
/// <summary>
/// Выход оператора: удаляет сессию по raw-токену, если он передан.
/// Выход оператора
/// </summary>
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
/// <param name="ct">Токен отмены.</param>
public async Task LogoutAsync(string? rawToken, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(rawToken))
@@ -67,10 +58,9 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP
}
/// <summary>
/// Разрешение операторской сессии по raw-токену: возвращает оператора или null (нет/протухла/оператор не активен).
/// Разрешение операторской сессии по raw-токену
/// </summary>
/// <param name="rawToken">Raw-токен из куки deal_operator_session.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Идентичность активного оператора или null.</returns>
public async Task<OperatorIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct)
{
@@ -85,7 +75,6 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
{
// Оператора ищем по денормализованному в сессию логину: он уникален и не меняется.
// Сессия разрешается только для активного оператора (решение ревью Task 2): удалённый
// или приостановленный оператор при живой сессии получает null — 401 на HTTP-слое.
var operatorRecord = await operatorAuthStore.FindByLoginAsync(session.Login, ct);
if (operatorRecord is not null && operatorRecord.Status == ActiveStatus)
@@ -4,49 +4,38 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Bootstrap оператора при старте (Ruling 1 этапа 7): идемпотентный seed из env DEAL_OPERATOR_*.
/// Bootstrap оператора при старте
/// </summary>
/// <remarks>
/// Шаг вызывается хостом при старте (встраивается в TenantBootstrapService или идёт отдельным
/// hosted-шагом после него — подключение в Task 3 вместе с EF-адаптером <see cref="IOperatorAuthStore"/>).
/// В Development при отсутствии кред используется дефолт operator/operator (зеркало dev-seed admin/admin);
/// в Production без env-кред шаг пропускается — оператора заводит админ позже через env и рестарт,
/// кода регистрации оператора нет. Секреты не логируются и не возвращаются.
/// </remarks>
public sealed class OperatorBootstrapService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
{
/// <summary>
/// Переменная окружения: логин оператора.
/// Переменная окружения
/// </summary>
public const string LoginEnvKey = "DEAL_OPERATOR_LOGIN";
/// <summary>
/// Переменная окружения: пароль оператора.
/// Переменная окружения
/// </summary>
public const string PasswordEnvKey = "DEAL_OPERATOR_PASSWORD";
/// <summary>
/// Дефолтный логин в Development при отсутствии env-кред (Ruling 1).
/// Дефолтный логин в Development при отсутствии env-кред.
/// </summary>
public const string DefaultOperatorLogin = "operator";
/// <summary>
/// Дефолтный пароль в Development при отсутствии env-кред (Ruling 1).
/// Дефолтный пароль в Development при отсутствии env-кред.
/// </summary>
public const string DefaultOperatorPassword = "operator";
private const string ActiveStatus = "active";
/// <summary>
/// Гарантирует наличие оператора: создаёт, если его ещё нет (идемпотентно).
/// Гарантирует наличие оператора
/// </summary>
/// <param name="login">Логин из env (<see cref="LoginEnvKey"/>) или null/пусто, если не задан.</param>
/// <param name="password">Пароль из env (<see cref="PasswordEnvKey"/>) или null/пусто, если не задан.</param>
/// <param name="allowDevelopmentDefaults">
/// true в Development: при отсутствии кред берутся дефолты operator/operator;
/// false (Production) при отсутствии кред — шаг пропускается, хост логирует warning.
/// </param>
/// <param name="ct">Токен отмены.</param>
/// <param name="allowDevelopmentDefaults">true в Development: при отсутствии кред берутся дефолты operator/operator; false (Production) при отсутствии кред — шаг пропускается, хост логирует warning.</param>
/// <returns>Логин оператора, присутствующего после шага (созданного или уже существовавшего); null — шаг пропущен.</returns>
public async Task<string?> EnsureOperatorAsync(
string? login,
@@ -6,26 +6,21 @@ using Deal.SharedKernel.Utilities;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Токены сессий: генерация raw-токена и его SHA-256-хэша для хранения (Ruling 6).
/// Токены сессий: генерация raw-токена и его SHA-256-хэша для хранения.
/// </summary>
/// <remarks>
/// В БД и куке никогда не фигурирует одно и то же: наружу (в куку) отдаётся raw-токен,
/// в хранилище пишется <see cref="HashToken"/> — SHA-256-хэш raw-токена.
/// Генерация url-safe токена — общий <see cref="UrlSafeToken"/> (Security review, C36).
/// </remarks>
public static class SessionTokens
{
// Случайные байты raw-токена (32 → 43 символа Base64Url).
private const int RawTokenByteLength = 32;
/// <summary>
/// Новый raw-токен: 32 случайных байта в Base64Url (без padding, без '+' и '/').
/// Новый raw-токен
/// </summary>
/// <returns>Строка токена длиной 43 символа.</returns>
public static string NewToken() => UrlSafeToken.New(RawTokenByteLength);
/// <summary>
/// SHA-256-хэш raw-токена в нижнем регистре (hex) — значение для поиска в БД.
/// SHA-256-хэш raw-токена в нижнем регистре
/// </summary>
/// <param name="rawToken">Raw-токен из <see cref="NewToken"/> (или от клиента).</param>
/// <returns>64 hex-символа.</returns>
@@ -5,33 +5,17 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Детектор подозрительной активности по логам безопасности (§10.5): правила поверх аудита.
/// Детектор подозрительной активности по логам безопасности
/// </summary>
/// <remarks>
/// <para>
/// Источник — append-only <c>public.audit_log</c> (<see cref="IAuditLogStore"/>), читается за окно [From, To]
/// (по умолчанию — последние <see cref="DefaultWindowHours"/> часов, не более <see cref="MaxScanRecords"/>
/// записей). Реализованные правила:
/// <list type="bullet">
/// <item><c>failed_logins_per_ip</c> — всплеск неудачных входов с одного IP;</item>
/// <item><c>failed_logins_per_login</c> — всплеск неудачных входов по одному логину;</item>
/// <item><c>many_ips_per_actor</c> — успешные входы одного актора с множества IP (угон сессии/перебор);</item>
/// <item><c>auth_failures_per_tenant</c> — повторные неудачные входы по тенанту (косвенно 401/429-серия).</item>
/// </list>
/// Пороговые значения — именованные константы; анализ — чистая функция над прочитанными записями, поэтому
/// покрывается юнит-тестами на фейковом хранилище. События 401/429 как таковые в аудит не пишутся (429 —
/// следствие серии неудач) — правило по тенанту агрегирует фактические неудачные входы.
/// </para>
/// </remarks>
public sealed class SuspiciousActivityService
{
/// <summary>
/// Размер окна анализа по умолчанию, часов (сутки).
/// Размер окна анализа по умолчанию, часов
/// </summary>
public const int DefaultWindowHours = 24;
/// <summary>
/// Предел разбираемых записей за окно (совпадает с лимитом выборки аудита).
/// Предел разбираемых записей за окно
/// </summary>
public const int MaxScanRecords = AuditService.MaxQueryLimit;
@@ -79,12 +63,12 @@ public sealed class SuspiciousActivityService
public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant";
/// <summary>
/// Уровень находки: высокий (серьёзная аномалия).
/// Уровень находки
/// </summary>
public const string SeverityHigh = "high";
/// <summary>
/// Уровень находки: средний (стоит посмотреть).
/// Уровень находки
/// </summary>
public const string SeverityMedium = "medium";
@@ -92,7 +76,7 @@ public sealed class SuspiciousActivityService
private readonly Func<DateTimeOffset> _clock;
/// <summary>
/// Создаёт детектор с системными часами UTC-«сейчас» (боевая регистрация).
/// Создаёт детектор с системными часами UTC-«сейчас»
/// </summary>
/// <param name="store">Хранилище аудита (append-only лента).</param>
public SuspiciousActivityService(IAuditLogStore store)
@@ -101,7 +85,7 @@ public sealed class SuspiciousActivityService
}
/// <summary>
/// Создаёт детектор с инъекцией часов (тесты фиксируют окно).
/// Создаёт детектор с инъекцией часов
/// </summary>
/// <param name="store">Хранилище аудита (append-only лента).</param>
/// <param name="clock">Источник «сейчас».</param>
@@ -118,7 +102,6 @@ public sealed class SuspiciousActivityService
/// </summary>
/// <param name="from">Начало окна (включительно); null — «сейчас минус <see cref="DefaultWindowHours"/> часов».</param>
/// <param name="to">Конец окна (включительно); null — «сейчас».</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сводка: окно, число разобранных записей, признак усечения и список находок.</returns>
public async Task<SuspiciousActivityDto> AnalyzeAsync(
DateTimeOffset? from,
@@ -6,19 +6,8 @@ using Deal.SharedKernel.Utilities;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис операторского реестра тенантов (GET /api/operator/tenants[/{id}], POST (создание),
/// suspend/unsuspend — план Task 7): чтение реестра + пользователей, создание тенанта (с провижинингом схемы) и
/// смена статуса приостановки.
/// Прикладной сервис операторского реестра тенантов
/// </summary>
/// <remarks>
/// Аудит (tenant_created/tenant_status_changed) пишет HTTP-слой (паттерн Task 4/5), сервис возвращает результат
/// с кодами ошибок; impersonation живёт в <see cref="AuthService.ImpersonateAsync"/> (создание tenant-сессии
/// пользователя). Создание с email (план Task 7: «email-опция создаёт сразу пользователя-владельца») заводит
/// пользователя с автоматическим одноразовым паролем — владелец получает его от оператора и меняет после первого
/// входа (прямой ввод пароля оператором в контракте не предусмотрен; см. <see cref="TenantCreateResultDto"/>).
/// Счётчики пользователей считаются по списку пользователей тенанта (порт <see cref="IAuthStore"/>); на масштабах
/// операторской админки N+1 осознан (сводка usage/лимитов появится в Task 8–10).
/// </remarks>
public sealed class TenantAdminService(
ITenantRepository tenantRepository,
IAuthStore authStore,
@@ -29,17 +18,11 @@ public sealed class TenantAdminService(
private const int InitialPasswordRandomByteCount = 12;
/// <summary>
/// Создаёт тенанта оператором (план Task 7): строка реестра (Status active) + провижининг схемы
/// (<see cref="TenantService.CreateTenantAsync"/>); при email — сразу пользователь-владелец с одноразовым паролем.
/// Создаёт тенанта оператором
/// </summary>
/// <param name="name">Имя тенанта (обязательно; обрезается).</param>
/// <param name="email">Email владельца (опционально): создаёт пользователя-владельца сразу, иначе владелец
/// заводится инвайтом (Ruling 2).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>
/// Ok=true — тенант создан (Tenant); при email дополнительно OwnerUserId/OwnerLogin/InitialPassword.
/// Ok=false — код ошибки (имя пусто, email невалиден/уже зарегистрирован); текст — HTTP-слой.
/// </returns>
/// <param name="email">Email владельца (опционально): создаёт пользователя-владельца сразу, иначе владелец заводится инвайтом.</param>
/// <returns>Ok=true — тенант создан (Tenant); при email дополнительно OwnerUserId/OwnerLogin/InitialPassword. Ok=false — код ошибки (имя пусто, email невалиден/уже зарегистрирован); текст — HTTP-слой.</returns>
public async Task<TenantCreateResultDto> CreateAsync(
string? name,
string? email,
@@ -60,8 +43,6 @@ public sealed class TenantAdminService(
return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorInvalidEmail);
}
// Глобальная уникальность email (users.login unique, Ruling 2): предпроверка до записи, чтобы занятый
// email не создавал тенанта без владельца (как JoinService, Task 6). Гонка закрыта unique-индексом.
var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct);
if (existingUser is not null)
{
@@ -102,9 +83,8 @@ public sealed class TenantAdminService(
}
/// <summary>
/// Список тенантов со счётчиками пользователей (реестр + счётчики, план Task 7).
/// Список тенантов со счётчиками пользователей.
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Тенанты в порядке создания (реестр) с числом пользователей каждого.</returns>
public async Task<IReadOnlyList<TenantListItemDto>> ListAsync(CancellationToken ct)
{
@@ -120,10 +100,9 @@ public sealed class TenantAdminService(
}
/// <summary>
/// Детали тенанта с пользователями (GET /api/operator/tenants/{id}).
/// Детали тенанта с пользователями
/// </summary>
/// <param name="id">Идентификатор тенанта.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Детали и пользователи тенанта (по CreatedAt) или null, если тенанта нет.</returns>
public async Task<TenantDetailDto?> GetAsync(Guid id, CancellationToken ct)
{
@@ -138,11 +117,10 @@ public sealed class TenantAdminService(
}
/// <summary>
/// Меняет статус тенанта (POST …/suspend → suspended, …/unsuspend → active; аудит пишет HTTP-слой).
/// Меняет статус тенанта
/// </summary>
/// <param name="id">Идентификатор тенанта.</param>
/// <param name="status">Новый статус — константа <c>TenantStatuses</c>.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: тенант не найден (404) или применение с признаком реального изменения (Changed).</returns>
public async Task<TenantStatusChangeResultDto> ChangeStatusAsync(
Guid id,
@@ -5,27 +5,24 @@ using Deal.SharedKernel.Tenants.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис реестра тенантов: создание тенанта и его списка.
/// Прикладной сервис реестра тенантов
/// </summary>
/// <remarks>Создание тенанта — единая транзакция по смыслу: запись в public.tenants и провижининг схемы.</remarks>
public sealed class TenantService(ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner)
{
/// <summary>
/// Создаёт тенанта (id = новый Guid в формате "N") и провижинит его схему.
/// Создаёт тенанта
/// </summary>
/// <param name="name">Имя тенанта.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt;).</returns>
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt).</returns>
public Task<TenantId> CreateTenantAsync(string name, CancellationToken ct) =>
CreateTenantAsync(name, Guid.NewGuid(), ct);
/// <summary>
/// Создаёт тенанта с явным id и провижинит его схему (bootstrap дефолтного тенанта, Ruling 8).
/// Создаёт тенанта с явным id и провижинит его схему.
/// </summary>
/// <param name="name">Имя тенанта.</param>
/// <param name="id">Идентификатор тенанта (определяет имя схемы).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt;).</returns>
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt).</returns>
public async Task<TenantId> CreateTenantAsync(
string name,
Guid id,
@@ -47,7 +44,6 @@ public sealed class TenantService(ITenantRepository tenantRepository, ITenantPro
/// <summary>
/// Возвращает список всех тенантов.
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Список тенантов.</returns>
public Task<IReadOnlyList<TenantRecordDto>> ListTenantsAsync(CancellationToken ct) =>
tenantRepository.ListAsync(ct);
@@ -3,20 +3,12 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Период-математика лимитов ИИ-бюджета (Task 8, Ruling 3 этапа 7): конец окна периода для ленивого
/// reset и пороги 80/100% для флагов Warned80/NotifiedExhausted.
/// Период-математика лимитов ИИ-бюджета
/// </summary>
/// <remarks>
/// Класс без состояния (детерминированные функции от аргументов) — безопасен для scoped/singleton-регистрации
/// (Task 9) и для прямого создания в адаптерах/тестах. Reset ленивый (Ruling 3): при чтении/записи, если
/// сейчас ≥ конца периода (PeriodStart+месяц/сутки), UsedTokens и флаги обнуляются и PeriodStart=now;
/// отдельного фонового цикла нет. Порог 80% считается целочисленно: <c>budget - budget/5</c> = floor(0.8·budget)
/// — без double и переполнения long.
/// </remarks>
public sealed class TokenBudgetService
{
/// <summary>
/// Проверяет, что период истёк (ленивый reset, Ruling 3): сейчас ≥ конца окна от PeriodStart.
/// Проверяет, что период истёк
/// </summary>
/// <param name="periodStart">Начало текущего периода.</param>
/// <param name="period">Тип периода (<see cref="TenantLimitPeriods"/>; любое иное значение трактуется как месяц).</param>
@@ -34,7 +26,7 @@ public sealed class TokenBudgetService
}
/// <summary>
/// Проверяет порог 80% бюджета (Ruling 3: тост «ИИ-бюджет израсходован на 80%», флаг Warned80).
/// Проверяет порог 80% бюджета.
/// </summary>
/// <param name="usedTokens">Использовано токенов с начала периода.</param>
/// <param name="budgetTokens">Бюджет периода.</param>
@@ -45,7 +37,7 @@ public sealed class TokenBudgetService
}
/// <summary>
/// Проверяет исчерпание бюджета (Ruling 3: «ИИ-бюджет исчерпан», флаг NotifiedExhausted; гейт Task 9).
/// Проверяет исчерпание бюджета.
/// </summary>
/// <param name="usedTokens">Использовано токенов с начала периода.</param>
/// <param name="budgetTokens">Бюджет периода.</param>
@@ -56,7 +48,7 @@ public sealed class TokenBudgetService
}
/// <summary>
/// Остаток бюджета до исчерпания (≥0; операторская сводка usage, Task 10).
/// Остаток бюджета до исчерпания.
/// </summary>
/// <param name="usedTokens">Использовано токенов с начала периода.</param>
/// <param name="budgetTokens">Бюджет периода.</param>
@@ -4,29 +4,23 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис истории расхода токенов (этап 10, T2): единая точка записи и чтения агрегатов.
/// Прикладной сервис истории расхода токенов
/// </summary>
/// <remarks>
/// Запись — append-only через <see cref="ITokenUsageEventStore.AppendAsync"/>; At=UTC-now проставляется здесь
/// (как у <see cref="AuditService"/>). Чтение агрегатов — для операторской аналитики (read-only).
/// </remarks>
public sealed class TokenUsageEventService(ITokenUsageEventStore store)
{
/// <summary>
/// Записывает событие расхода токенов (append-only; At = сейчас, UTC).
/// Записывает событие расхода токенов
/// </summary>
/// <param name="record">Событие (At перезаписывается сервисом).</param>
/// <param name="ct">Токен отмены.</param>
public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct)
{
await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct);
}
/// <summary>
/// Агрегаты расхода по фильтру и группировке (прокси порта; чтение — аналитика оператора).
/// Агрегаты расхода по фильтру и группировке
/// </summary>
/// <param name="query">Фильтр/группировка.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Строки агрегатов.</returns>
public Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct) =>
store.AggregateAsync(query, ct);