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

Первый коммит: модульный монолит ядра (.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
@@ -0,0 +1,168 @@
using System.Text.Json;
using Deal.Api.Http;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинт проверки подключения AI-провайдера: POST /api/ai/check (Ruling 7/8, api-map §4.10).
/// </summary>
/// <remarks>
/// «Только для Settings-экрана» (Ruling 8): фронт жмёт «Проверить подключение» (store.js
/// checkAiConnection) БЕЗ тела — сервер читает АКТИВНУЮ конфигурацию провайдера тенанта
/// (настройки <c>aiProvider</c> + <c>aiConfigs</c> с расшифровкой ключа через <see cref="ISecretCipher"/>;
/// 1:1 с ai_svc._cfg(), ai.py L2533), вызывает порт <see cref="IAiConnectionChecker"/> и отдаёт
/// {ok, message} + статус провайдера. Требует сессию: 401 {detail} (формат прототипа).
/// Резолв scoped-зависимостей — через RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints:
/// DI-биндинг параметров выполняется до тела, а ISettingsStore требует tenant-контекст запроса).
/// </remarks>
public static class AiCheckEndpoint
{
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
private const string ApiGroupPrefix = "/api";
// Путь проверки подключения AI-провайдера.
private const string AiCheckPath = "/ai/check";
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
private const string OpenApiTag = "settings";
/// <summary>
/// Регистрирует POST /api/ai/check.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapAiCheckEndpoint(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
group.MapPost(AiCheckPath, CheckAsync);
return app;
}
// POST /api/ai/check: проверка соединения с активным AI-провайдером тенанта.
private static async Task<IResult> CheckAsync(HttpContext context, CancellationToken ct)
{
if (context.GetCurrentUser() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
// Резолв после 401-гейта: ISettingsStore — scoped на TenantDbContext (tenant-контекст запроса).
ISettingsStore store = context.RequestServices.GetRequiredService<ISettingsStore>();
ISecretCipher secretCipher = context.RequestServices.GetRequiredService<ISecretCipher>();
IAiConnectionChecker checker = context.RequestServices.GetRequiredService<IAiConnectionChecker>();
AiCheckRequest checkRequest = await BuildActiveCheckRequestAsync(store, secretCipher, ct);
AiCheckResultDto result = await checker.CheckAsync(checkRequest, ct);
return Results.Ok(result);
}
// Собирает запрос проверки из активной конфигурации провайдера (1:1 с ai_svc._cfg()).
// store: KV-хранилище настроек тенанта.
// secretCipher: Шифр секретов (расшифровка apiKey).
// ct: Токен отмены.
// Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ.
// Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными
// переопределениями (Ruling 1); пустое переопределение base/model → дефолт каталога
// (семантика «cfg.get(...) or meta[...]» прототипа).
private static async Task<AiCheckRequest> BuildActiveCheckRequestAsync(
ISettingsStore store,
ISecretCipher secretCipher,
CancellationToken ct)
{
string providerId = await ReadActiveProviderIdAsync(store, ct);
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId);
// Неизвестный id (ручное вмешательство в БД — PATCH-гейт SettingsService не даёт сохранить):
// HTTP не выполняется — ответит SSRF-гейт checker (allowlist).
if (meta is null)
{
return new AiCheckRequest(providerId, string.Empty, string.Empty, string.Empty, IsLocal: false, ApiStyle: null);
}
AiConfigSetting config = await ReadEffectiveConfigAsync(store, providerId, ct);
string apiKey = secretCipher.Decrypt(config.ApiKey);
string baseUrl = string.IsNullOrEmpty(config.BaseUrl) ? meta.Base : config.BaseUrl;
string model = string.IsNullOrEmpty(config.Model)
? meta.Models.FirstOrDefault() ?? string.Empty
: config.Model;
return new AiCheckRequest(providerId, baseUrl, model, apiKey, meta.Local, meta.ApiStyle);
}
// Читает активный провайдер: сохранённый aiProvider или дефолт (повреждённое значение — дефолт).
// store: KV-хранилище настроек тенанта.
// ct: Токен отмены.
// Возвращает: id провайдера.
private static async Task<string> ReadActiveProviderIdAsync(ISettingsStore store, CancellationToken ct)
{
SettingValue? row = await store.GetAsync(SettingsKeys.AiProvider, ct);
if (row is null)
{
return SettingsDefaults.AiProvider;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
if (document.RootElement.ValueKind == JsonValueKind.String)
{
return document.RootElement.GetString() ?? SettingsDefaults.AiProvider;
}
}
catch (JsonException)
{
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
}
return SettingsDefaults.AiProvider;
}
// Эффективный конфиг провайдера: дефолт SettingsDefaults, перекрытый сохранённым aiConfigs.
// store: KV-хранилище настроек тенанта.
// providerId: Активный провайдер (id из каталога).
// ct: Токен отмены.
// Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт.
private static async Task<AiConfigSetting> ReadEffectiveConfigAsync(ISettingsStore store, string providerId, CancellationToken ct)
{
AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId];
SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct);
if (row is null)
{
return defaults;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
JsonElement root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty(providerId, out JsonElement entry)
&& entry.ValueKind == JsonValueKind.Object)
{
return new AiConfigSetting(
ApiKey: ReadField(entry, "apiKey") ?? defaults.ApiKey,
BaseUrl: ReadField(entry, "baseUrl") ?? defaults.BaseUrl,
Model: ReadField(entry, "model") ?? defaults.Model);
}
}
catch (JsonException)
{
// Повреждённая строка — дефолт (не роняем проверку).
}
return defaults;
}
// Читает строковое поле объекта конфигурации (имена полей camelCase, как пишет SettingsService).
// entry: JSON-объект конфигурации провайдера.
// field: Имя поля (apiKey/baseUrl/model).
// Возвращает: Значение или null, если поле отсутствует/не строка.
private static string? ReadField(JsonElement entry, string field)
{
return entry.TryGetProperty(field, out JsonElement value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
}
}
@@ -0,0 +1,100 @@
using Deal.Api.Events;
using Deal.Api.Http;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты ИИ-предложений: POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords
/// (план Task 14 L476479; прототип dashboard_routes.py L395409).
/// </summary>
/// <remarks>
/// Контракт 1:1 с прототипом и api-map §3.2 L120121: suggest-columns → <c>{ok:true, created:N}</c>
/// либо <c>{ok:false, reason, cooldown?}</c>; suggest-keywords → <c>{ok:true, keywords:[…]}</c> либо
/// <c>{ok:false, reason}</c>. Причины — мягкие ошибки (HTTP 200 с ok:false + reason), статусы 4xx/5xx
/// не мапятся. Оба требуют сессию: 401 {detail} без куки (как остальные эндпоинты контейнеров); порт
/// IColumnSuggester резолвится из RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст
/// запроса). При успехе suggest-columns публикуется SSE-toast «ИИ предложил колонок: N — откройте и
/// решите» (sparkles, 1:1 с suggest.py L162); boards_changed НЕ шлём (Ruling 5: фронт перечитывает
/// доски сам после ok). Публикации — из эндпоинта (Ruling 5): без подписчиков — no-op.
/// </remarks>
public static class AiSuggestEndpoints
{
// Префикс группы AI-эндпоинтов этапа (роутер dashboard, prefix="/api"; пути L395/L406).
private const string AiGroupPrefix = "/api/ai";
// Путь предложения колонок (dashboard_routes.py L395).
private const string SuggestColumnsPath = "/suggest-columns";
// Путь предложения ключевых слов (dashboard_routes.py L404).
private const string SuggestKeywordsPath = "/suggest-keywords";
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
private const string OpenApiTag = "dashboard";
// SSE-тип события тоста (Ruling 5; api.js слушает 'toast').
private const string ToastEventType = "toast";
// Текст тоста после успешных предложений колонок (suggest.py L162, 1:1).
private const string SuggestToastTextFormat = "ИИ предложил колонок: {0} — откройте и решите";
// Иконка тоста предложений колонок (sparkles, 1:1 с прототипом).
private const string SparklesIcon = "sparkles";
/// <summary>
/// Регистрирует POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapAiSuggestEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(AiGroupPrefix).WithTags(OpenApiTag);
group.MapPost(SuggestColumnsPath, SuggestColumnsAsync);
group.MapPost(SuggestKeywordsPath, SuggestKeywordsAsync);
return app;
}
// POST /api/ai/suggest-columns: анализ «Неразобранного» и создание колонок-предложений.
// Ответ — результат порта 1:1: {ok:true, created:N} — доски suggested=true созданы (эндпоинт шлёт
// SSE-toast), {ok:false, reason} (+ cooldown) — мягкая причина (HTTP 200). Кулдаун/«мало карточек»/
// «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3).
private static async Task<IResult> SuggestColumnsAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
IColumnSuggester suggester = context.RequestServices.GetRequiredService<IColumnSuggester>();
SuggestColumnsResultDto result = await suggester.SuggestColumnsAsync(ct);
if (result.Ok)
{
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
broker.Publish(
context.GetCurrentUser()!.TenantId,
ToastEventType,
new { text = string.Format(SuggestToastTextFormat, result.Created), icon = SparklesIcon });
}
return Results.Ok(result);
}
// POST /api/ai/suggest-keywords: слова-маркеры сферы по карточкам (настройки «Сфера и ключи»).
// Ответ — результат порта 1:1: {ok:true, keywords:[…]} (≤60) либо {ok:false, reason} (HTTP 200).
private static async Task<IResult> SuggestKeywordsAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
IColumnSuggester suggester = context.RequestServices.GetRequiredService<IColumnSuggester>();
SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(ct);
return Results.Ok(result);
}
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
}
@@ -0,0 +1,211 @@
using Deal.Api.Http;
using Deal.Api.Middleware;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
using Microsoft.Extensions.Options;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
using CookieOptions = Deal.Api.Configuration.CookieOptions;
namespace Deal.Api.Endpoints;
/// <summary>
/// HTTP-эндпоинты аутентификации (группа /api/auth). Контракт 1:1 с прототипом auth_routes.py.
/// </summary>
/// <remarks>
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10).
/// Кука сессии выставляется на login и change-password (свежий токен). Сообщения об ошибках —
/// фиксированные строки прототипа.
/// </remarks>
public static class AuthEndpoints
{
private const string InvalidCredentialsDetail = "Неверный логин или пароль";
private const string WrongOldPasswordDetail = "Текущий пароль неверен";
private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
private const string TenantSuspendedDetail = "Учётная запись приостановлена. Обратитесь к оператору";
private const string AuthGroupPrefix = "/api/auth";
private const string AuthOpenApiTag = "auth";
/// <summary>
/// Регистрирует группу /api/auth: login, logout, me, change-password.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag);
// Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP
// ручки входа; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
group.MapPost("/logout", LogoutAsync);
group.MapGet("/me", MeAsync);
group.MapPost("/change-password", ChangePasswordAsync);
return app;
}
// POST /api/auth/login: проверка учётных данных, выдача куки сессии; результат пишется в аудит (Task 4/7).
// До AuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
private static async Task<IResult> LoginAsync(
LoginRequest body,
AuthService authService,
AuditService auditService,
IOptions<CookieOptions> cookieOptions,
HttpContext context,
CancellationToken ct,
LoginAttemptGuard loginAttemptGuard)
{
string? attemptedLogin = NormalizeLogin(body.Login);
// Защита входа (план Task 11, Ruling 5): блокировка ключа ip|login до проверки учётных данных —
// 429 «Слишком много попыток входа…» (в dev при RateLimit:Enabled=false гвард выключен).
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
{
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
}
var result = await authService.LoginAsync(body.Login, body.Password, ct);
// Приостановленный тенант: вход заблокирован (Ruling 10(5)). Отдельный текст от «неверных учётных
// данных»; tenant_login_failed пишется с tenantId и ActorId (ревью Task 4: failed-логины suspended-тенанта).
// Решение Task 7: HTTP-код 403 (а не 401) — учётка существует, доступ запрещён; приёмочный текст плана
// Task 16 формулирует «login 401» — отклонение зафиксировано для api-map/техдок в task-7-report.md.
if (result.Error == LoginResultDto.ErrorTenantSuspended)
{
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant,
ActorId: result.UserId,
TenantId: result.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct);
return EndpointResults.Forbidden(TenantSuspendedDetail);
}
if (result.Login is null || result.Token is null)
{
// Пустой/пробельный login и неверные учётные данные — одно сообщение (семантика прототипа).
// Аудит tenant_login_failed пишем только для реальной попытки (непустой логин), без пароля (Ruling 4);
// счётчик неудач гварда растёт там же — пустые логины ключа не имеют (блокирует только auth-политика).
if (attemptedLogin is not null)
{
await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant,
ActorId: null,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
}
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
}
// Успешный вход сбрасывает счётчик неудач ключа ip|login (Ruling 5).
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.TenantLoginOk,
AuditActorTypes.Tenant,
ActorId: result.UserId,
TenantId: result.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
return Results.Ok(new { ok = true, login = result.Login });
}
// POST /api/auth/logout: удаление сессии по токену из куки и очистка куки (всегда ok).
// Если удалённая сессия была impersonation — пишется аудит impersonation_stopped (Task 7, ревью: полный аудит).
private static async Task<IResult> LogoutAsync(
AuthService authService,
AuditService auditService,
IOptions<CookieOptions> cookieOptions,
HttpContext context,
CancellationToken ct)
{
var cookieName = cookieOptions.Value.Name;
var rawToken = context.Request.Cookies[cookieName];
// Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса).
CurrentUser? user = context.GetCurrentUser();
var logout = await authService.LogoutAsync(rawToken, ct);
if (logout is not null)
{
// Актор — оператор, начавший impersonation (маркер сессии); тенант — для фильтра TenantId.
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.ImpersonationStopped,
AuditActorTypes.Operator,
ActorId: logout.OperatorId,
TenantId: logout.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
}
// Выход пользователя тенанта (этап 10, T1): событие пишется при живой разрешённой сессии.
if (user is not null)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
}
context.Response.Cookies.Delete(cookieName);
return Results.Ok(new { ok = true });
}
// GET /api/auth/me: проверка живой сессии.
private static IResult MeAsync(HttpContext context)
{
var user = context.GetCurrentUser();
if (user is null)
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
return Results.Ok(new { login = user.Login, ok = true });
}
// POST /api/auth/change-password: смена пароля и перевыпуск куки (свежая сессия).
private static async Task<IResult> ChangePasswordAsync(
ChangePasswordRequest body,
AuthService authService,
IOptions<CookieOptions> cookieOptions,
HttpContext context,
CancellationToken ct)
{
var user = context.GetCurrentUser();
if (user is null)
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct);
if (!result.Ok || result.NewToken is null)
{
// Семантика прототипа: код ошибки различает «старый пароль неверен» и «слишком короткий».
var detail = result.Error == ChangePasswordResultDto.ErrorTooShort
? PasswordTooShortDetail
: WrongOldPasswordDetail;
return EndpointResults.BadRequest(detail);
}
// Старые сессии удалены внутри сервиса; выдаём клиенту свежую куку.
SessionCookieWriter.Append(context, cookieOptions.Value, result.NewToken);
return Results.Ok(new { ok = true });
}
// Нормализованная попытка логина для аудита (нижний регистр/обрезка, как AuthService); null — писать нечего.
// login: Логин из тела запроса.
// Возвращает: Нормализованный логин или null при пустом/пробельном входе.
private static string? NormalizeLogin(string? login)
{
string? normalized = login?.Trim().ToLowerInvariant();
return string.IsNullOrEmpty(normalized) ? null : normalized;
}
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
}
@@ -0,0 +1,432 @@
using System.Text.Json;
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Tenants.Application;
namespace Deal.Api.Endpoints;
/// <summary>
/// Детальные операции карточки: создание локальной, «взять в работу», патч, ссылки, файлы,
/// напоминания, очистка «Отклонено» — продолжение группы /api/cards (этап 9, T6).
/// </summary>
/// <remarks>
/// Единый контракт /api/cards (R5): операции проектной карточки (патч полей, ссылки, файлы, напоминания)
/// теперь живут на том же ресурсе карточки. Список/чтение/перенос/комментарии/корзина — в
/// <see cref="CardsEndpoints"/>; здесь — уникальные подпути. Все мутации возвращают обновлённую
/// единую карточку (чтение после записи через <see cref="CardsService"/>). Все эндпоинты требуют сессию.
/// </remarks>
public static class CardDetailsEndpoints
{
// Префикс группы (общий с CardsEndpoints).
private const string CardsGroupPrefix = "/api/cards";
// Статический сегмент «взять в работу» (регистрируется до /{cardId}).
private const string TakePath = "/take";
// Статический сегмент очистки «Отклонено» (регистрируется до /{cardId}).
private const string ClearRejectedPath = "/clear-rejected";
// Параметрический сегмент карточки (PATCH /{cardId}).
private const string CardIdPath = "/{cardId}";
// Вложенный путь добавления ссылки.
private const string LinksPath = "/{cardId}/links";
// Вложенный путь удаления ссылки.
private const string LinkItemPath = "/{cardId}/links/{linkId}";
// Вложенный путь загрузки вложений (multipart, поле files).
private const string FilesPath = "/{cardId}/files";
// Вложенный путь скачивания вложения (поток + attachment).
private const string FileDownloadPath = "/{cardId}/files/{fileId}/download";
// Вложенный путь удаления вложения.
private const string FileItemPath = "/{cardId}/files/{fileId}";
// Вложенный путь установки/снятия напоминания.
private const string ReminderPath = "/{cardId}/reminder";
// Вложенный путь «напомнить позже».
private const string ReminderSnoozePath = "/{cardId}/reminder/snooze";
// OpenAPI-тег группы.
private const string OpenApiTag = "cards";
// 404: карточка не найдена.
private const string CardNotFoundDetail = "Карточка не найдена";
// 400: тело PATCH не JSON-объект.
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
// 400: POST файлов без multipart/form-data.
private const string FormExpectedDetail = "Ожидается multipart/form-data";
// 400: POST напоминания без поля at.
private const string ReminderAtMissingDetail = "Поле at (epoch-ms) обязательно";
// 404 download: объекта нет в хранилище.
private const string FileNotFoundInStorageDetail = "Файл не найден в MinIO";
// 410 download: у записи файла нет objectKey.
private const string FileNotSavedDetail = "Файл не сохранён в объектном хранилище";
// Content-Type скачивания по умолчанию.
private const string DownloadContentTypeFallback = "application/octet-stream";
// Символ, убираемый из имени файла для Content-Disposition.
private const string FileNameQuoteCharacter = "\"";
/// <summary>
/// Регистрирует уникальные подпути /api/cards (создание, take, патч, ссылки, файлы, напоминания).
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapCardDetailsEndpoints(this IEndpointRouteBuilder app)
{
var cards = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag);
// Статические сегменты (/take, /clear-rejected) ДО /{cardId}; вложенные — за /{cardId}.
cards.MapPost("", CreateCardAsync);
cards.MapPost(TakePath, TakeCardAsync);
cards.MapPost(ClearRejectedPath, ClearRejectedAsync);
cards.MapPatch(CardIdPath, PatchCardAsync);
cards.MapPost(LinksPath, AddLinkAsync);
cards.MapDelete(LinkItemPath, RemoveLinkAsync);
cards.MapPost(FilesPath, UploadFilesAsync);
cards.MapGet(FileDownloadPath, DownloadFileAsync);
cards.MapDelete(FileItemPath, RemoveFileAsync);
cards.MapPost(ReminderPath, SetReminderAsync);
cards.MapDelete(ReminderPath, ClearReminderAsync);
cards.MapPost(ReminderSnoozePath, SnoozeReminderAsync);
return app;
}
// POST /api/cards: ручное создание «локальной» карточки. Ответ — созданная карточка.
private static async Task<IResult> CreateCardAsync(CreateCardRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
// Аудит создания карточки (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct);
return await ReadCardAsync(context, created.Id, ct);
}
// POST /api/cards/take {cardId}: «взять в работу» — перенос карточки в planned. Ответ — карточка.
private static async Task<IResult> TakeCardAsync(TakeCardRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
return card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, card.Id, ct);
}
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
private static async Task<IResult> ClearRejectedAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
int cleared = await service.ClearRejectedAsync(ct);
return Results.Ok(new { ok = true, cleared });
}
// PATCH /api/cards/{cardId}: точечная правка полей (title/summary/contact/tzText/stack/budget).
// Тело читается как произвольный JSON-объект (presence-aware): явный null чистящих полей
// (budget:null, stack:null) не теряется типизированным биндингом. Ответ — обновлённая карточка.
private static async Task<IResult> PatchCardAsync(string cardId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
IReadOnlyDictionary<string, JsonElement>? body = await ReadPatchBodyAsync(context, ct);
if (body is null)
{
return EndpointResults.BadRequest(InvalidBodyDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await service.PatchCardAsync(cardId, body, ct);
return card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
}
// POST /api/cards/{cardId}/links {name?,url}: добавить ссылку. Ответ — карточка.
private static async Task<IResult> AddLinkAsync(string cardId, CardLinkRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardResultDto result = await service.AddLinkAsync(
cardId,
body.Name ?? string.Empty,
body.Url ?? string.Empty,
ct);
if (result.Error is not null)
{
return EndpointResults.BadRequest(result.Error);
}
return result.Card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
}
// DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка.
private static async Task<IResult> RemoveLinkAsync(string cardId, string linkId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardResultDto result = await service.RemoveLinkAsync(cardId, linkId, ct);
return result.Card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
}
// POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files).
// Ответ — обновлённая карточка (с новыми files). Карточки нет → 404 до записи объектов. Каждый файл:
// имя/ContentType/поток/длина → CardsService.AddFileAsync. Ранний null — гонка (404).
private static async Task<IResult> UploadFilesAsync(string cardId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
if (await cardsService.GetCardAsync(cardId, ct) is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
IFormCollection form;
try
{
form = await context.Request.ReadFormAsync(ct);
}
catch (InvalidOperationException)
{
// Тело не multipart/form-data — ReadFormAsync бросает; фронт так не шлёт.
return EndpointResults.BadRequest(FormExpectedDetail);
}
foreach (IFormFile file in form.Files)
{
await using Stream content = file.OpenReadStream();
CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
if (entry is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
}
return await ReadCardAsync(context, cardId, ct);
}
// GET /api/cards/{cardId}/files/{fileId}/download: поток содержимого вложения.
// Карточки/записи нет → 404; пустой objectKey → 410; объекта нет в хранилище/сбой → 404. Ответ — поток
// с Content-Length/Content-Type из дескриптора; Content-Disposition attachment, имя без кавычек.
private static async Task<IResult> DownloadFileAsync(string cardId, string fileId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardFileDto? entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
if (entry is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
if (string.IsNullOrWhiteSpace(entry.ObjectKey))
{
return EndpointResults.Gone(FileNotSavedDetail);
}
IFileStorage storage = context.RequestServices.GetRequiredService<IFileStorage>();
FileMeta? meta;
Stream? stream;
try
{
meta = await storage.StatAsync(entry.ObjectKey, ct);
stream = meta is null ? null : await storage.GetAsync(entry.ObjectKey, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception)
{
return EndpointResults.NotFound(FileNotFoundInStorageDetail);
}
if (meta is null || stream is null)
{
return EndpointResults.NotFound(FileNotFoundInStorageDetail);
}
context.Response.ContentLength = meta.Size;
string contentType = string.IsNullOrWhiteSpace(meta.ContentType)
? DownloadContentTypeFallback
: meta.ContentType;
return Results.Stream(stream, contentType, fileDownloadName: ToDownloadFileName(entry.Name));
}
// DELETE /api/cards/{cardId}/files/{fileId}: открепить файл. Ответ — карточка.
private static async Task<IResult> RemoveFileAsync(string cardId, string fileId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct);
return card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
}
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
private static async Task<IResult> SetReminderAsync(string cardId, ReminderSetRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
if (body.At is null)
{
return EndpointResults.BadRequest(ReminderAtMissingDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardResultDto result = await service.SetReminderAsync(cardId, body.At.Value, ct);
if (result.Error is not null)
{
return EndpointResults.BadRequest(result.Error);
}
return result.Card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
}
// DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка.
private static async Task<IResult> ClearReminderAsync(string cardId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
return await service.ClearReminderAsync(cardId, ct)
? await ReadCardAsync(context, cardId, ct)
: EndpointResults.NotFound(CardNotFoundDetail);
}
// POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка.
private static async Task<IResult> SnoozeReminderAsync(string cardId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
return await service.SnoozeReminderAsync(cardId, ct)
? await ReadCardAsync(context, cardId, ct)
: EndpointResults.NotFound(CardNotFoundDetail);
}
// Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет).
// context: Контекст запроса (для резолва CardsService).
// cardId: Id карточки.
// ct: Токен отмены.
// Возвращает: 200 с единой карточкой либо 404.
private static async Task<IResult> ReadCardAsync(HttpContext context, string cardId, CancellationToken ct)
{
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
return card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(card);
}
// Имя файла для Content-Disposition без кавычек «"».
// name: Имя файла как в метаданных записи.
// Возвращает: Имя, безопасное для заголовка.
private static string ToDownloadFileName(string name) => name.Replace(FileNameQuoteCharacter, string.Empty);
// Переводит тело POST /api/cards в начальные поля сервиса (поля 1:1 с CardLocalCreateDto).
// body: Тело запроса.
// Возвращает: DTO модуля для CardsService.CreateLocalCardAsync.
private static CardLocalCreateDto ToCreateLocalDto(CreateCardRequest body)
{
return new CardLocalCreateDto(
Title: body.Title,
Summary: body.Summary,
Stack: body.Stack,
Budget: body.Budget,
Contact: body.Contact,
TzText: body.TzText,
ContainerId: body.ContainerId ?? body.Stage);
}
// Читает тело PATCH как произвольный JSON-объект: ключ → JsonElement (presence-aware).
// context: Контекст запроса.
// ct: Токен отмены.
// Возвращает: Словарь ключей тела либо null — тело не JSON-объект.
private static async Task<IReadOnlyDictionary<string, JsonElement>?> ReadPatchBodyAsync(HttpContext context, CancellationToken ct)
{
try
{
return await JsonSerializer.DeserializeAsync<Dictionary<string, JsonElement>>(
context.Request.Body,
options: null,
cancellationToken: ct);
}
catch (JsonException)
{
return null;
}
}
// Разрешена ли сессия запроса.
// context: Контекст запроса.
// Возвращает: True — сессия есть.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
}
@@ -0,0 +1,447 @@
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Events;
using Deal.Api.Http;
using Deal.Modules.Cards.Application;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Tenants.Application;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты карточек и поиска: GET /api/cards[?containerId=], /cards/counts, /cards/{id},
/// mark-all-seen/mark-col-seen, move/trash/restore/DELETE, clear-col, comments, reclassify (batch + {id}),
/// GET /api/search (этап 9, T6).
/// </summary>
/// <remarks>
/// Единый контракт /api/cards (R5): старые ручки /api/leads, /api/projects, /api/boards и /api/columns
/// упразднены. GET /cards →
/// {items}; counts — плоская wire-форма {new, &lt;col&gt;: {count, new}, learning, ml, ai}; move → обновлённая
/// карточка; restore → {ok, col}; clear-col → {ok, cleared}; comments → {comments}; search →
/// {cards, messages: []}. 400 «Неизвестный контейнер» при несуществующем containerId; 404 «Карточка не
/// найдена» — null-результаты сервисов, 400-тексты — константы CardsService.
/// ⚠ Статические сегменты (counts, mark-all-seen, mark-col-seen, clear-col, reclassify) регистрируются ДО
/// /cards/{cardId}. Все эндпоинты требуют сессию: 401 {detail}; сервисы резолвятся из RequestServices
/// ПОСЛЕ проверки сессии.
/// </remarks>
public static class CardsEndpoints
{
// Префикс группы карточек.
private const string CardsGroupPrefix = "/api/cards";
// Префикс группы поиска (единственный эндпоинт группы — /api/search).
private const string ApiGroupPrefix = "/api";
// Путь поиска (GET).
private const string SearchPath = "/search";
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
private const string OpenApiTag = "dashboard";
// 404: карточка не найдена (dashboard_routes.py _lead_or_404 L9296).
private const string CardNotFoundDetail = "Карточка не найдена";
// 400 GET /cards: containerId не существует.
private const string UnknownColumnDetail = "Неизвестный контейнер";
// Инициатор перехода при ручном переносе — действие пользователя (R4 этапа 9).
private const string UserActor = "user";
// SSE-тип события завершения переклассификации (этап 12, остаток 2; api.js слушает 'cards_reclassified').
private const string ReclassifiedEventType = "cards_reclassified";
// Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса.
private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true };
/// <summary>
/// Регистрирует группы /api/cards и /api (карточки + поиск). Статические сегменты — до /cards/{cardId}.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapCardsEndpoints(this IEndpointRouteBuilder app)
{
var leads = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag);
// Статические сегменты ДО /cards/{cardId}: ASP.NET Core отдаёт приоритет литералам, порядок регистрации
// сохранён для читаемости.
leads.MapGet("", ListCardsAsync);
leads.MapGet("/counts", CountsAsync);
leads.MapPost("/mark-all-seen", MarkAllSeenAsync);
leads.MapPost("/mark-col-seen", MarkColSeenAsync);
leads.MapPost("/clear-col", ClearColAsync);
leads.MapPost("/reclassify", ReclassifyAsync);
leads.MapGet("/{cardId}", GetCardAsync);
leads.MapPost("/{cardId}/reclassify", ReclassifyOneAsync);
leads.MapPost("/{cardId}/move", MoveAsync);
leads.MapPost("/{cardId}/trash", TrashAsync);
leads.MapPost("/{cardId}/restore", RestoreAsync);
leads.MapDelete("/{cardId}", DeleteAsync);
leads.MapPost("/{cardId}/comments", AddCommentAsync);
app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag).MapGet(SearchPath, SearchAsync);
return app;
}
// GET /api/cards?containerId=: карточки контейнера (или все карточки дашборда); 400 «Неизвестный контейнер».
// Параметр col принят как алиас containerId (совместимость со старым фронтом).
private static async Task<IResult> ListCardsAsync(string? containerId, string? col, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
string? target = string.IsNullOrEmpty(containerId) ? col : containerId;
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
if (!string.IsNullOrEmpty(target) && !await IsKnownContainerAsync(target, context, ct))
{
return EndpointResults.BadRequest(UnknownColumnDetail);
}
IReadOnlyList<CardDto> cards = await cardsService.ListCardsAsync(target, ct);
return Results.Ok(new { items = cards });
}
// GET /api/cards/counts: плоская wire-форма счётчиков {new, &lt;col&gt;:{count,new}, learning, ml, ai} (L161163, §4.1 L257).
private static async Task<IResult> CountsAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardCountsDto counts = await cardsService.CountsAsync(ct);
// Разворачивание CardCountsDto: колонки — корневые ключи (counts L268–279), служебные — фиксированные.
var wire = new Dictionary<string, object> { ["new"] = counts.New };
foreach ((string col, CardColumnCountDto column) in counts.Columns)
{
wire[col] = column;
}
wire["learning"] = counts.Learning;
wire["ml"] = counts.Ml;
wire["ai"] = counts.Ai;
return Results.Ok(wire);
}
// GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168).
private static async Task<IResult> GetCardAsync(string cardId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
return card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(card);
}
// POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}.
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
await cardsService.MarkSeenAsync(cardId: null, col: null, ct);
return Results.Ok(new { ok = true });
}
// POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {ok:true}.
private static async Task<IResult> MarkColSeenAsync(MarkColBody body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
if (body.Col is null)
{
// Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ
// карточек (truthiness python, L250256) — эндпоинт защищает от вызова с null (прототип: 422).
return EndpointResults.BadRequest(UnknownColumnDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
await cardsService.MarkSeenAsync(cardId: null, col: body.Col, ct);
return Results.Ok(new { ok = true });
}
// POST /api/cards/{cardId}/move {to}: перенос карточки между контейнерами (этап 9, R4).
// Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый
// доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания
// (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при
// несуществующем контейнере, 404 — карточки нет.
private static async Task<IResult> MoveAsync(string cardId, MoveBody body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ICardMover mover = context.RequestServices.GetRequiredService<ICardMover>();
CardMoveResultDto outcome = await mover.MoveAsync(cardId, body.To ?? string.Empty, UserMoveContext, ct);
if (outcome.Error is not null)
{
return EndpointResults.BadRequest(outcome.Error);
}
if (!outcome.Exists)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
// Аудит переноса карточки (этап 10, T1): цель — минимальный безопасный идентификатор.
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? unified = await cardsService.GetCardAsync(cardId, ct);
return unified is null
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(unified);
}
// POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203207); ответ {ok:true}; 404.
private static async Task<IResult> TrashAsync(string cardId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.TrashCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
// Аудит отправки карточки в корзину (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
return Results.Ok(new { ok = true });
}
// POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404.
private static async Task<IResult> RestoreAsync(string cardId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
string? col = await cardsService.RestoreCardAsync(cardId, ct);
if (col is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
// Аудит возврата карточки из корзины/архива (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
return Results.Ok(new { ok = true, col });
}
// DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217221); ответ {ok:true}; 404.
private static async Task<IResult> DeleteAsync(string cardId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
bool deleted = await cardsService.DeleteForeverAsync(cardId, ct);
if (!deleted)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
// Аудит удаления карточки навсегда (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct);
return Results.Ok(new { ok = true });
}
// POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400.
private static async Task<IResult> ClearColAsync(ClearColBody body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
ClearColResultDto result = await cardsService.ClearColAsync(body.Col ?? string.Empty, ct);
return result.Error is not null
? EndpointResults.BadRequest(result.Error)
: Results.Ok(new { ok = true, cleared = result.Cleared });
}
// POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404.
private static async Task<IResult> AddCommentAsync(string cardId, CommentBody body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
AddCommentResultDto result = await cardsService.AddCommentAsync(cardId, body.Text ?? string.Empty, ct);
if (result.Error is not null)
{
return EndpointResults.BadRequest(result.Error);
}
if (result.Comments is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
// Аудит добавления комментария (этап 10, T1): текст комментария в детали не пишется.
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct);
return Results.Ok(new { comments = result.Comments });
}
// POST /api/cards/reclassify: переклассификация «Неразобранного» (все карточки либо ids).
// Тело ids опционально (фронт шлёт запрос без тела — все карточки inbox). Проход синхронный; при занятом
// проходе ответ {started:false, busy:true}. Поля started/busy/attempted сохранены ради совместимости,
// добавлены reclassified/moved/kept/trashed/skipped/usedAi/reason. Аудит — card_reclassified; после
// успешного прохода (reclassified &gt; 0) публикуется SSE cards_reclassified {reclassified,moved}.
// body: Тело запроса (ids — опционально).
// context: Контекст запроса.
// ct: Токен отмены.
private static async Task<IResult> ReclassifyAsync(ReclassifyBody? body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct);
await AppendReclassifyAuditAsync(context, result, ct);
PublishReclassified(context, result);
return Results.Ok(ToReclassifyWire(result));
}
// POST /api/cards/{cardId}/reclassify: переклассификация одной карточки; 404 «Карточка не найдена».
// cardId: Id карточки (c_...).
// context: Контекст запроса.
// ct: Токен отмены.
private static async Task<IResult> ReclassifyOneAsync(string cardId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct);
await AppendReclassifyAuditAsync(context, result, ct);
PublishReclassified(context, result);
return Results.Ok(ToReclassifyWire(result));
}
// Wire-форма итога переклассификации (camelCase; сохранены started/busy/attempted).
// result: Итог прохода.
// Возвращает: Объект ответа эндпоинта.
private static object ToReclassifyWire(ReclassifyResultDto result) => new
{
started = result.Started,
busy = result.Busy,
attempted = result.Attempted,
reclassified = result.Reclassified,
moved = result.Moved,
kept = result.Kept,
trashed = result.Trashed,
skipped = result.Skipped,
usedAi = result.UsedAi,
reason = result.Reason,
};
// Публикует SSE cards_reclassified после успешного прохода (Ruling 5: публикации — из Api).
// Публикуется только когда проход реально выполнен и что-то изменил (started и
// reclassified &gt; 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка
// минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op.
// context: Контекст запроса (тенант-канал сессии).
// result: Итог прохода.
private static void PublishReclassified(HttpContext context, ReclassifyResultDto result)
{
if (!result.Started || result.Reclassified == 0)
{
return;
}
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
broker.Publish(
context.GetCurrentUser()!.TenantId,
ReclassifiedEventType,
new { reclassified = result.Reclassified, moved = result.Moved });
}
// Аудит переклассификации: пишется только когда проход реально что-то изменил.
// context: Контекст запроса.
// result: Итог прохода.
// ct: Токен отмены.
private static Task AppendReclassifyAuditAsync(HttpContext context, ReclassifyResultDto result, CancellationToken ct)
{
if (result.Reclassified == 0)
{
return Task.CompletedTask;
}
return AuditAppender.AppendTenantAsync(
context,
AuditEvents.CardReclassified,
new { attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, trashed = result.Trashed },
ct);
}
// GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254256). Ответ {leads, messages: []}.
private static async Task<IResult> SearchAsync(string? q, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
IReadOnlyList<CardDto> leads = await cardsService.SearchCardsAsync(q, ct);
// messages всегда []: telegram-сообщений здесь нет, фронт их не читает.
return Results.Ok(new { cards = leads, messages = Array.Empty<object>() });
}
// ── Внутреннее ─────────────────────────────────────────────────────────
// Существует ли контейнер с таким id (служебная зона/стадия/доска).
// col: Значение query-параметра containerId (непустое).
// context: Контекст запроса (для резолва ContainersService).
// ct: Токен отмены.
// Возвращает: True — контейнер допустим для фильтра.
private static async Task<bool> IsKnownContainerAsync(string col, HttpContext context, CancellationToken ct)
{
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
return await containers.GetAsync(col, ct) is not null;
}
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
}
@@ -0,0 +1,8 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/auth/change-password. Входящий JSON — camelCase (oldPassword, newPassword).
/// </summary>
/// <param name="OldPassword">Текущий пароль.</param>
/// <param name="NewPassword">Новый пароль (минимум 8 символов).</param>
public sealed record ChangePasswordRequest(string OldPassword, string NewPassword);
@@ -0,0 +1,7 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/admin/check-message (1:1 с CheckMessageBody, dashboard_routes.py L7273).
/// </summary>
/// <param name="Text">Текст сообщения для проверки фильтром (этап 1 + этап 2 тестера).</param>
public sealed record CheckMessageRequest(string Text);
@@ -0,0 +1,304 @@
using System.Text.Json;
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Tenants.Application;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты контейнеров (колонок/стадий/зон) и состояния колонок: GET/POST /api/containers,
/// PATCH /{id}/accept, PATCH/DELETE /{id}, POST /reorder, GET/PATCH state (этап 9, T4/T6).
/// </summary>
/// <remarks>
/// Единый реестр контейнеров приходит на смену /api/boards + /api/columns (R5): список, создание,
/// частичное обновление, принятие ИИ-предложения, удаление с переносом карточек в inbox, reorder и
/// состояние колонок (colState). Все эндпоинты требуют сессию: 401 {detail}. ContainersService
/// резолвится из RequestServices ПОСЛЕ проверки сессии.
/// </remarks>
public static class ContainersEndpoints
{
// Префикс группы контейнеров.
private const string ContainersGroupPrefix = "/api/containers";
// OpenAPI-тег группы.
private const string OpenApiTag = "containers";
// 404 PATCH/accept: контейнер не найден.
private const string ContainerNotFoundDetail = "Контейнер не найден";
// 400: отсутствующий/явный null name контейнера.
private const string ContainerNameRequiredDetail = "Укажите название колонки";
// 400 reorder: отсутствующий/явный null order.
private const string ContainerOrderRequiredDetail = "Не указан порядок колонок";
// 400 PATCH: тело не JSON-объект.
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
// Опции разбора PATCH-тела: web-дефолты (camelCase + регистронезависимость).
private static readonly JsonSerializerOptions RequestJsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Регистрирует группы /api/containers (контейнеры + состояние колонок).
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapContainersEndpoints(this IEndpointRouteBuilder app)
{
var containers = app.MapGroup(ContainersGroupPrefix).WithTags(OpenApiTag);
containers.MapGet("", ListContainersAsync);
containers.MapPost("", CreateContainerAsync);
containers.MapPost("/reorder", ReorderContainersAsync);
containers.MapGet("/state", GetColumnsStateAsync);
containers.MapPatch("/{containerId}/state", PatchColumnStateAsync);
containers.MapPost("/{containerId}/accept", AcceptSuggestedAsync);
containers.MapPatch("/{containerId}", PatchContainerAsync);
containers.MapDelete("/{containerId}", DeleteContainerAsync);
return app;
}
// GET /api/containers?space=: список контейнеров пространства (или всех) со счётчиками.
private static async Task<IResult> ListContainersAsync(string? space, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
return Results.Ok(new { items = await containers.ListAsync(space, ct) });
}
// POST /api/containers: создать контейнер; ответ {id}.
private static async Task<IResult> CreateContainerAsync(ContainerCreateRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
if (body.Name is null)
{
return EndpointResults.BadRequest(ContainerNameRequiredDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto created = await containers.CreateAsync(
new ContainerCreateDto(
Name: body.Name,
Description: body.Description ?? string.Empty,
Color: body.Color,
Space: body.Space ?? ContainerSpaces.Dashboard,
Kind: body.Kind ?? ContainerKinds.Board,
Suggested: body.Suggested ?? false,
Rules: NormalizeWireRules(body.Rules),
Note: body.Note ?? string.Empty),
ct);
// Аудит создания контейнера (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct);
return Results.Ok(new { id = created.Id });
}
// PATCH /api/containers/{id}: частичное обновление; ответ {id}; 404 «Контейнер не найден».
private static async Task<IResult> PatchContainerAsync(string containerId, JsonElement body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
if (body.ValueKind != JsonValueKind.Object)
{
return EndpointResults.BadRequest(InvalidBodyDetail);
}
foreach (JsonProperty property in body.EnumerateObject())
{
if (string.Equals(property.Name, "name", StringComparison.OrdinalIgnoreCase)
&& property.Value.ValueKind == JsonValueKind.Null)
{
return EndpointResults.BadRequest(ContainerNameRequiredDetail);
}
}
ContainerPatchRequest? patchBody;
try
{
patchBody = body.Deserialize<ContainerPatchRequest>(RequestJsonOptions);
}
catch (JsonException)
{
return EndpointResults.BadRequest(InvalidBodyDetail);
}
if (patchBody is null)
{
return EndpointResults.BadRequest(InvalidBodyDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto? updated = await containers.PatchAsync(
containerId,
new ContainerPatchDto(
patchBody.Name,
patchBody.Description,
patchBody.Color,
patchBody.Collapsed,
patchBody.Suggested,
patchBody.Note,
NormalizeWireRules(patchBody.Rules),
patchBody.Policy),
ct);
if (updated is null)
{
return EndpointResults.NotFound(ContainerNotFoundDetail);
}
// Аудит изменения контейнера (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
return Results.Ok(new { id = updated.Id });
}
// POST /api/containers/{id}/accept: принять ИИ-предложение (suggested=false).
private static async Task<IResult> AcceptSuggestedAsync(string containerId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto? accepted = await containers.AcceptSuggestedAsync(containerId, ct);
if (accepted is null)
{
return EndpointResults.NotFound(ContainerNotFoundDetail);
}
// Аудит изменения контейнера (принятие ИИ-предложения) — этап 10, T1.
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
return Results.Ok(accepted);
}
// DELETE /api/containers/{id}: удалить контейнер; карточки → «Неразобранное» новыми.
private static async Task<IResult> DeleteContainerAsync(string containerId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
int moved = await containers.DeleteAsync(containerId, ct);
// Аудит удаления контейнера (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct);
return Results.Ok(new { ok = true, movedToInbox = moved });
}
// POST /api/containers/reorder: порядок контейнеров пространства; ответ {ok:true}.
private static async Task<IResult> ReorderContainersAsync(OrderBody body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
if (body.Order is null)
{
return EndpointResults.BadRequest(ContainerOrderRequiredDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
await containers.ReorderAsync(body.Space ?? ContainerSpaces.Dashboard, body.Order, ct);
return Results.Ok(new { ok = true });
}
// GET /api/containers/state: свёрнутость/ширина всех колонок (colState).
private static async Task<IResult> GetColumnsStateAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
IReadOnlyDictionary<string, ColumnStateDto> state = await containers.GetColStateAsync(ct);
var wire = new Dictionary<string, object>(StringComparer.Ordinal);
foreach ((string colId, ColumnStateDto colState) in state)
{
wire[colId] = ToWireState(colState);
}
return Results.Ok(wire);
}
// PATCH /api/containers/{id}/state: merge патча в состояние колонки; ответ — состояние этой колонки.
private static async Task<IResult> PatchColumnStateAsync(string containerId, ColStateBody body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ColumnStateDto merged = await containers.PatchColStateAsync(
containerId,
new ColumnStateDto(body.Collapsed, body.Width),
ct);
return Results.Ok(ToWireState(merged));
}
// Правила из wire → каноничный ContainerRulesDto: отсутствующие группы становятся пустыми списками.
// rules: Правила из тела запроса (null — «не меняются/нет правил»).
// Возвращает: Каноничные правила с не-null группами; null — правил в теле нет.
private static ContainerRulesDto? NormalizeWireRules(ContainerRulesDto? rules)
{
if (rules is null)
{
return null;
}
return rules with
{
Mode = rules.Mode ?? string.Empty,
Direction = rules.Direction ?? Array.Empty<string>(),
Keywords = rules.Keywords ?? Array.Empty<string>(),
Stack = rules.Stack ?? Array.Empty<string>(),
Grade = rules.Grade ?? Array.Empty<string>(),
Exclude = rules.Exclude ?? Array.Empty<string>(),
Budget = rules.Budget is null ? null : rules.Budget with { Cur = rules.Budget.Cur ?? string.Empty },
Levels = rules.Levels ?? Array.Empty<string>(),
Locations = rules.Locations ?? Array.Empty<string>(),
Types = rules.Types ?? Array.Empty<string>(),
Prices = rules.Prices is null ? null : rules.Prices with { Cur = rules.Prices.Cur ?? string.Empty },
};
}
// Состояние колонки → wire-объект только с заданными полями (collapsed/width), без null.
// state: Состояние колонки (могут быть null-поля).
// Возвращает: Словарь из не-null полей состояния.
private static Dictionary<string, object> ToWireState(ColumnStateDto state)
{
var wire = new Dictionary<string, object>();
if (state.Collapsed is { } collapsed)
{
wire["collapsed"] = collapsed;
}
if (state.Width is not null)
{
wire["width"] = state.Width;
}
return wire;
}
// Разрешена ли сессия запроса.
// context: Контекст запроса.
// Возвращает: True — сессия есть.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
}
@@ -0,0 +1,540 @@
using System.Text.Json;
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Telegram.Application;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты /api/discovery: задачи поиска, кандидаты, чёрный список, лог, генерация ключей (Ruling 11, api-map §3.8).
/// </summary>
/// <remarks>
/// 13 эндпоинтов 1:1 с <c>backend/app/routers/discovery_routes.py</c> (prefix /api/discovery): tasks
/// (list/create/patch/delete/start/pause), generate-keywords (мягкая ошибка HTTP 200 {keywords: [], error},
/// Ruling 11), candidates (статус-фильтр), join/reject (ручные, вне квот воркера), blacklist, log. Тела ответов —
/// DTO модуля Discovery (camelCase, §4.8 L353355) и {items: [...]} для списков (конвенция api-map §1);
/// 404-семантика сервисов (null/KeyError python) — «Задача не найдена»/«Кандидат не найден»,
/// 400-семантика — <see cref="DiscoveryValidationException"/> (ValueError python) с текстом причины 1:1.
/// Ручной join — как worker-авто-join (Task 18): RPC Join через <see cref="ITelegramGateway"/> → строка каталога
/// Dialogs (монитор on) + зеркало через <see cref="DialogsService.AddDiscoveredMonitoredAsync"/> → фоновый первый
/// разбор (<see cref="TelegramBackfillScheduler"/>, python-_spawn) → снятие чёрного списка → mark_joined(auto:false);
/// ошибка Telegram → 400 с текстом причины. Все эндпоинты требуют сессию: 401 {detail} (Ruling 10); сервисы
/// резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped — TenantDbContext схемы тенанта, паттерн SettingsEndpoints).
/// </remarks>
public static class DiscoveryEndpoints
{
// Префикс группы /api/discovery (python: router prefix, discovery_routes.py L26).
private const string DiscoveryGroupPrefix = "/api/discovery";
// OpenAPI-тег группы (в прототипе роутер discovery — discovery_routes.py).
private const string DiscoveryOpenApiTag = "discovery";
// Путь списка задач (GET).
private const string TasksPath = "/tasks";
// Путь одной задачи: обновление (PATCH) / удаление (DELETE).
private const string TaskPath = "/tasks/{task_id}";
// Путь запуска поиска (POST).
private const string TaskStartPath = "/tasks/{task_id}/start";
// Путь паузы поиска (POST).
private const string TaskPausePath = "/tasks/{task_id}/pause";
// Путь ИИ-генерации ключевых слов по описанию задачи (POST).
private const string TaskGenerateKeywordsPath = "/tasks/{task_id}/generate-keywords";
// Путь списка кандидатов задачи со статус-фильтром (GET).
private const string TaskCandidatesPath = "/tasks/{task_id}/candidates";
// Путь ручного вступления в кандидата (POST).
private const string CandidateJoinPath = "/candidates/{dialog_id}/join";
// Путь отклонения кандидата в чёрный список (POST).
private const string CandidateRejectPath = "/candidates/{dialog_id}/reject";
// Путь чёрного списка (GET) и снятия записи (DELETE).
private const string BlacklistPath = "/blacklist";
// Путь записи чёрного списка (DELETE).
private const string BlacklistItemPath = "/blacklist/{dialog_id}";
// Путь лога задачи (GET).
private const string TaskLogPath = "/tasks/{task_id}/log";
// 404 create/start/patch/candidates/log: задачи нет (python _task_or_404 L8387).
private const string TaskNotFoundDetail = "Задача не найдена";
// 404 join/reject: кандидата нет (python _candidate_or_404 L9094).
private const string CandidateNotFoundDetail = "Кандидат не найден";
// 400 join: уже вступили (python L235237).
private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
// 400 reject: источник уже вступили (python L256258).
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
// 400 join: ошибка Telegram при вступлении (python L240242, текст с @username).
private const string JoinFailedFormat = "Не удалось вступить в @{0}: {1}";
// Причина отклонения вручную для чёрного списка/лога (python L260: reason="отклонено вручную").
private const string ManualRejectReason = "отклонено вручную";
// Мягкая ошибка generate-keywords: ИИ выключен (python _ai_unavailable_reason L99100).
private const string AiDisabledDetail = "ИИ выключен в настройках (aiEnabled)";
// Мягкая ошибка generate-keywords: описания нет (python L201202).
private const string NoDescriptionDetail = "У задачи нет описания — по нему генерируются ключи";
// Страховочный потолок числа сгенерированных ключей (python _KEYWORDS_LIMIT L31: промпт просит 10–16).
private const int KeywordsLimit = 30;
// Потолок длины одного ключа (python _KEYWORD_LENGTH_LIMIT L33: короткие фразы для поиска Telegram).
private const int KeywordLengthLimit = 60;
/// <summary>
/// Регистрирует группу /api/discovery: 13 эндпоинтов (tasks + generate-keywords + candidates + join/reject + blacklist + log).
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapDiscoveryEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(DiscoveryGroupPrefix).WithTags(DiscoveryOpenApiTag);
group.MapGet(TasksPath, ListTasksAsync);
group.MapPost(TasksPath, CreateTaskAsync);
group.MapPatch(TaskPath, PatchTaskAsync);
group.MapDelete(TaskPath, DeleteTaskAsync);
group.MapPost(TaskStartPath, StartTaskAsync);
group.MapPost(TaskPausePath, PauseTaskAsync);
group.MapPost(TaskGenerateKeywordsPath, GenerateKeywordsAsync);
group.MapGet(TaskCandidatesPath, ListCandidatesAsync);
group.MapPost(CandidateJoinPath, JoinCandidateAsync);
group.MapPost(CandidateRejectPath, RejectCandidateAsync);
group.MapGet(BlacklistPath, ListBlacklistAsync);
group.MapDelete(BlacklistItemPath, RemoveBlacklistAsync);
group.MapGet(TaskLogPath, TaskLogAsync);
return app;
}
// GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141143).
private static async Task<IResult> ListTasksAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
IReadOnlyList<DiscoveryTaskDto> items = await tasks.ListAsync(ct);
return Results.Ok(new { items });
}
// POST /api/discovery/tasks: создать задачу поиска (create_task L146151; дефолты — в сервисе).
private static async Task<IResult> CreateTaskAsync(DiscoveryTaskCreateBody body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
try
{
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto task = await tasks.CreateAsync(ToDraft(body), ct);
return Results.Ok(task);
}
catch (DiscoveryValidationException exception)
{
return EndpointResults.BadRequest(exception.Message);
}
}
// PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154161; 404/400).
private static async Task<IResult> PatchTaskAsync(string task_id, DiscoveryTaskPatchBody body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
try
{
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.PatchAsync(task_id, ToPatch(body), ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
}
catch (DiscoveryValidationException exception)
{
return EndpointResults.BadRequest(exception.Message);
}
}
// DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164168).
private static async Task<IResult> DeleteTaskAsync(string task_id, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
bool deleted = await tasks.DeleteAsync(task_id, ct);
return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail);
}
// POST /api/discovery/tasks/{task_id}/start: запуск поиска (start_task L171179; пустые ключи → 400).
private static async Task<IResult> StartTaskAsync(string task_id, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
try
{
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.StartAsync(task_id, ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
}
catch (DiscoveryValidationException exception)
{
return EndpointResults.BadRequest(exception.Message);
}
}
// POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181187).
private static async Task<IResult> PauseTaskAsync(string task_id, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.PauseAsync(task_id, ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
}
// POST /api/discovery/tasks/{task_id}/generate-keywords: ИИ-ключи по описанию задачи
// (generate_keywords L189211). ИИ выключен/недоступен/нет описания → HTTP 200 {keywords: [], error}.
// Очистка ключей ответа — CleanKeywords (python _clean_keywords L111128: ≤30, ≤60
// символов, дедуп casefold); описание режет до 4000 сам адаптер (GrpcAiTools.MaxDescriptionCodePoints).
// Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины.
private static async Task<IResult> GenerateKeywordsAsync(string task_id, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
ISettingsStore settings = context.RequestServices.GetRequiredService<ISettingsStore>();
if (!await ReadAiEnabledAsync(settings, ct))
{
return Results.Ok(new { keywords = Array.Empty<string>(), error = AiDisabledDetail });
}
string description = (task.Description ?? string.Empty).Trim();
if (description.Length == 0)
{
return Results.Ok(new { keywords = Array.Empty<string>(), error = NoDescriptionDetail });
}
try
{
IAiTools aiTools = context.RequestServices.GetRequiredService<IAiTools>();
AiGenerateKeywordsResultDto result = await aiTools.GenerateKeywordsAsync(description, ct);
if (result.Ok)
{
return Results.Ok(new { keywords = CleanKeywords(result.Keywords) });
}
// Недоступность провайдера/сервиса — мягкая ошибка для UI (Ruling 11), HTTP 200.
return Results.Ok(new { keywords = Array.Empty<string>(), error = result.Error ?? ServiceUnavailableText });
}
catch (NotSupportedException exception)
{
// Локальный режим: ai-service не подключён — инструменты недоступны (LocalAiTools, Task 15).
return Results.Ok(new { keywords = Array.Empty<string>(), error = exception.Message });
}
}
// GET /api/discovery/tasks/{task_id}/candidates?status=: кандидаты задачи с фильтром
// new|review|joined|rejected (list_candidates L216224; невалидный статус — пустой список).
private static async Task<IResult> ListCandidatesAsync(string task_id, string? status, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
IReadOnlyList<DiscoveryCandidateDto> items = await candidates.ListAsync(task_id, status, ct);
return Results.Ok(new { items });
}
// POST /api/discovery/candidates/{dialog_id}/join: ручное вступление вне квот (join_candidate L226251).
// RPC Join → строка каталога Dialogs (монитор on) + зеркало → фоновый первый разбор → снятие чёрного списка →
// mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины.
private static async Task<IResult> JoinCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct);
if (row is null)
{
return EndpointResults.NotFound(CandidateNotFoundDetail);
}
if (row.Status == DiscoveryCandidateStatuses.Joined)
{
return EndpointResults.BadRequest(AlreadyJoinedDetail);
}
string username = (row.Username ?? string.Empty).Trim().TrimStart('@');
try
{
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
await gateway.JoinAsync(username, ct);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
string reason = TelegramEndpoints.GatewayErrorText(exception);
return EndpointResults.BadRequest(string.Format(JoinFailedFormat, username, reason));
}
// Источник в каталоге (монитор on, backfilled=false) + монитор-зеркало telegram-service
// (python add_dialog_monitored L242; решение T18: локальную строку пишет Api-слой).
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
await dialogs.AddDiscoveredMonitoredAsync(dialog_id, row.Name, username, row.Kind, row.Hue, ct);
// Догон последних сообщений — в фоне: join из UI не должен висеть на паузах backfill
// (python _spawn(_backfill_quiet) L244245; источник уже в каталоге и мониторится).
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfill(dialog_id);
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
await blacklist.RemoveAsync(dialog_id, ct);
try
{
DiscoveryCandidateDto? joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct);
return joined is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(joined);
}
catch (DiscoveryValidationException exception)
{
return EndpointResults.BadRequest(exception.Message);
}
}
// POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список
// (reject_candidate L253264; уже вступившего — нельзя, 400).
private static async Task<IResult> RejectCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct);
if (row is null)
{
return EndpointResults.NotFound(CandidateNotFoundDetail);
}
if (row.Status == DiscoveryCandidateStatuses.Joined)
{
return EndpointResults.BadRequest(JoinedRejectDetail);
}
try
{
DiscoveryCandidateDto? rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct);
return rejected is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(rejected);
}
catch (DiscoveryValidationException exception)
{
return EndpointResults.BadRequest(exception.Message);
}
}
// GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269271).
private static async Task<IResult> ListBlacklistAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
IReadOnlyList<DiscoveryBlacklistDto> items = await blacklist.ListAsync(ct);
return Results.Ok(new { items });
}
// DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274277).
private static async Task<IResult> RemoveBlacklistAsync(string dialog_id, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
await blacklist.RemoveAsync(dialog_id, ct);
return Results.Ok(new { ok = true });
}
// GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282285), события от новых к старым.
private static async Task<IResult> TaskLogAsync(string task_id, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
DiscoveryLogService log = context.RequestServices.GetRequiredService<DiscoveryLogService>();
IReadOnlyList<DiscoveryLogDto> items = await log.TaskLogAsync(task_id, ct);
return Results.Ok(new { items });
}
/// <summary>
/// Ключи из ответа ИИ: строки без пустых/длинных и повторов (python _clean_keywords L111128).
/// </summary>
/// <remarks>Повтор считается по <c>casefold</c> python: здесь — регистронезависимое сравнение
/// (RU/EN-ключи; StringComparer.OrdinalIgnoreCase). Потолок списка — <see cref="KeywordsLimit"/>.</remarks>
/// <param name="raw">Сырые ключи ответа модели (null — пусто).</param>
/// <returns>Очищенные ключи (не более 30, каждый ≤60 символов).</returns>
public static IReadOnlyList<string> CleanKeywords(IEnumerable<string>? raw)
{
if (raw is null)
{
return Array.Empty<string>();
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var outList = new List<string>();
foreach (string? item in raw)
{
if (item is null)
{
continue;
}
string keyword = item.Trim();
if (keyword.Length == 0 || keyword.Length > KeywordLengthLimit || !seen.Add(keyword))
{
continue;
}
outList.Add(keyword);
if (outList.Count >= KeywordsLimit)
{
break;
}
}
return outList;
}
// Фолбэк-текст недоступного ИИ, если адаптер причину не вернул (мягкая ошибка, Ruling 11).
private const string ServiceUnavailableText = "ИИ недоступен — повторите попытку через несколько секунд";
// Читает настройку aiEnabled (KV; отсутствие строки — дефолт SettingsDefaults).
// settings: KV-хранилище настроек тенанта.
// ct: Токен отмены.
// Возвращает: True — ИИ включён (ветки выключателя отрабатывает вызывающий, Ruling 10/11).
private static async Task<bool> ReadAiEnabledAsync(ISettingsStore settings, CancellationToken ct)
{
SettingValue? row = await settings.GetAsync(SettingsKeys.AiEnabled, ct);
if (row is null)
{
return SettingsDefaults.AiEnabled;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
return document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False
? document.RootElement.GetBoolean()
: SettingsDefaults.AiEnabled;
}
catch (JsonException)
{
return SettingsDefaults.AiEnabled;
}
}
// Маппит тело создания в черновик сервиса (значения пограничные передаются как есть — дефолты в сервисе).
// body: Тело запроса (wire-поля camelCase).
// Возвращает: Черновик создания задачи (DiscoveryTaskDraft).
private static DiscoveryTaskDraft ToDraft(DiscoveryTaskCreateBody body)
{
return new DiscoveryTaskDraft
{
Name = body.Name,
Description = body.Description ?? string.Empty,
Keywords = body.Keywords,
MinSubscribers = body.MinSubscribers,
Lang = body.Lang,
Threshold = body.Threshold,
SampleSize = body.SampleSize,
PlanJoins = body.PlanJoins,
AutoJoin = body.AutoJoin,
};
}
// Маппит тело патча в сервисный патч (не-null значения; как python model_dump(exclude_none=True)).
// body: Тело запроса (wire-поля camelCase).
// Возвращает: Патч задачи (DiscoveryTaskPatch).
private static DiscoveryTaskPatch ToPatch(DiscoveryTaskPatchBody body)
{
return new DiscoveryTaskPatch
{
Name = body.Name,
Description = body.Description,
Keywords = body.Keywords,
MinSubscribers = body.MinSubscribers,
Lang = body.Lang,
Threshold = body.Threshold,
SampleSize = body.SampleSize,
PlanJoins = body.PlanJoins,
AutoJoin = body.AutoJoin,
};
}
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
}
@@ -0,0 +1,116 @@
using Deal.Api.Events;
using Deal.Api.Http;
namespace Deal.Api.Endpoints;
/// <summary>
/// SSE-поток событий канбана: GET /api/events (Ruling 5; прототип events_routes.py L1538).
/// </summary>
/// <remarks>
/// Открывает <c>text/event-stream</c> с подпиской на канал тенанта сессии (singleton SseBroker).
/// События пишутся по мере поступления; при тишине 15 с отправляется ping-комментарий ": ping" —
/// соединение держится (переподключение EventSource, api.js L62104). Завершение — по отвалу клиента
/// (CancellationToken = RequestAborted); отписка — в finally. Без сессии — 401 {detail} (Ruling 10,
/// паттерн остальных эндпоинтов). Заголовки: Content-Type text/event-stream, Cache-Control: no-cache,
/// X-Accel-Buffering: no (запрет буферизации прокси, иначе ping/события задерживаются).
/// </remarks>
public static class EventsEndpoint
{
// Путь потока (роутер events, events_routes.py L12: prefix="/api").
private const string EventsPath = "/api/events";
// OpenAPI-тег группы (в прототипе — роутер events_routes.py).
private const string OpenApiTag = "events";
// Тип контента потока (events_routes.py L32).
private const string EventStreamContentType = "text/event-stream";
// Директива кеширования: поток не кешируется (events_routes.py L34).
private const string NoCacheHeaderValue = "no-cache";
// Отключение буферизации ответа nginx-прокси (events_routes.py L35).
private const string NoBufferingHeaderValue = "no";
// Ping-комментарий: строки протокола SSE, начинающиеся с ':', клиент игнорирует.
private const string PingComment = ": ping\n\n";
// Интервал ping при тишине: держим соединение (events_routes.py L24: timeout=15).
private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(15);
/// <summary>
/// Регистрирует GET /api/events.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapEventsEndpoint(this IEndpointRouteBuilder app)
{
app.MapGet(EventsPath, StreamEventsAsync).WithTags(OpenApiTag);
return app;
}
// GET /api/events: поток text/event-stream канала тенанта сессии.
// context: Контекст запроса (сессия — HttpContext.Items).
// ct: Отмена запроса: клиент отвалился — завершаем поток и отписываемся.
private static async Task StreamEventsAsync(HttpContext context, CancellationToken ct)
{
CurrentUser? user = context.GetCurrentUser();
if (user is null)
{
await EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail).ExecuteAsync(context);
return;
}
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
SseSubscription subscription = broker.Subscribe(user.TenantId);
try
{
HttpResponse response = context.Response;
response.ContentType = EventStreamContentType;
response.Headers["Cache-Control"] = NoCacheHeaderValue;
response.Headers["X-Accel-Buffering"] = NoBufferingHeaderValue;
while (true)
{
using var pingTimeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
pingTimeout.CancelAfter(PingInterval);
try
{
await subscription.Events.WaitToReadAsync(pingTimeout.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Тишина 15 с — ping держит соединение; отмену клиента ловит внешний catch.
await WriteFrameAsync(response, PingComment, ct);
continue;
}
while (subscription.Events.TryRead(out SseEvent? sseEvent))
{
await WriteFrameAsync(response, sseEvent.RenderFrame(), ct);
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Клиент закрыл соединение — штатное завершение потока.
}
catch (IOException)
{
// Сброс соединения клиентом (закрытая вкладка/обрыв сети): ответ уже не доставить.
}
finally
{
broker.Unsubscribe(user.TenantId, subscription.Id);
}
}
// Пишет frame в поток ответа и сбрасывает буфер — события уходят сразу (не пачкой).
// response: Ответ (stream уже начат).
// frame: Frame протокола SSE.
// ct: Токен отмены запроса.
private static async Task WriteFrameAsync(HttpResponse response, string frame, CancellationToken ct)
{
await response.WriteAsync(frame, ct);
await response.Body.FlushAsync(ct);
}
}
@@ -0,0 +1,79 @@
using Deal.Api.Http;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Тестер фильтра входящих: POST /api/admin/check-message (Ruling 8, api-map §3.2 L109, §4.10 L364).
/// </summary>
/// <remarks>
/// Имитация этапов пайплайна для тестера в настройках — 1:1 с <c>dashboard_routes.py</c> L267284:
/// этап 1 считают детерминированные правила <see cref="IncomingRules"/> (<c>stage1_plain</c>, pipeline.py
/// L94–124) по настройкам тенанта; ответ — <c>{stage1:{pass,reason}, stage2:{pass,reason,skipped}, passed}</c>.
/// ИИ-фильтр этапа 2 на этапе 2 ВСЕГДА skipped (Ruling 4/8, план Task 10 L377380): если этап-1 не прошёл —
/// <c>stage2={pass:false,reason:null,skipped:true}, passed:false</c>; иначе — <c>stage2={pass:true,reason:null,
/// skipped:true}, passed:true</c> (реальный ИИ-фильтр — этап 6, ветка ошибки ИИ прототипа L281 к skipped
/// не относится — там ИИ реально зовётся). kind/kw результата правил наружу НЕ отдаются (в ответе только
/// pass/reason — как в прототипе); они нужны мониторингу отсева этапа 4.
/// Эндпоинт требует сессию: 401 {detail} (Ruling 10). IncomingRules резолвится из RequestServices ПОСЛЕ
/// проверки сессии (scoped на TenantDbContext — паттерн SettingsEndpoints/MlEndpoints).
/// </remarks>
public static class FilterTesterEndpoints
{
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
private const string ApiGroupPrefix = "/api";
// Путь тестера фильтра входящих (dashboard_routes.py L267).
private const string CheckMessagePath = "/admin/check-message";
// OpenAPI-тег группы (эндпоинт Settings-экрана, Ruling 8).
private const string OpenApiTag = "settings";
/// <summary>
/// Регистрирует POST /api/admin/check-message.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapFilterTesterEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
group.MapPost(CheckMessagePath, CheckAsync);
return app;
}
// POST /api/admin/check-message: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267284).
private static async Task<IResult> CheckAsync(CheckMessageRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
// Резолв после 401-гейта: IncomingRules — scoped на TenantDbContext (tenant-контекст запроса).
IncomingRules incomingRules = context.RequestServices.GetRequiredService<IncomingRules>();
IncomingRulesResult stage1 = await incomingRules.CheckAsync(body.Text, ct);
// Ответ 1:1 с прототипом: stage2 на этапе 2 всегда skipped (Ruling 4/8, план L377380).
if (!stage1.Pass)
{
return Results.Ok(new
{
stage1 = new { pass = stage1.Pass, reason = stage1.Reason },
stage2 = new { pass = false, reason = (string?)null, skipped = true },
passed = false,
});
}
return Results.Ok(new
{
stage1 = new { pass = stage1.Pass, reason = stage1.Reason },
stage2 = new { pass = true, reason = (string?)null, skipped = true },
passed = true,
});
}
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
}
+112
View File
@@ -0,0 +1,112 @@
using Deal.Api.Http;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Публичный эндпоинт активации инвайта: POST /api/join (Ruling 2/11 этапа 7).
/// </summary>
/// <remarks>
/// Ручка не требует сессии (публичная; фронт её не вызывает — API-only, curl/будущий UI). Тело
/// {code, email, name?, password} → JoinService: валидация кода/email/пароля, CAS-резервирование инвайта,
/// создание тенанта (при пустом TenantId — с провижинингом схемы) и пользователя. Кука НЕ ставится: после
/// активации клиент входит обычным /api/auth/login (план Task 6). Успех — {ok:true, login}; ошибки — 400
/// {detail} с фиксированным текстом причины (все отказы активации — 400, включая истёкший инвайт: слой
/// эндпоинта, см. Task 6; Ruling 2 называет это «410-семантикой» — ресурс больше недоступен). Результат
/// пишется в аудит — invite_joined (актор — новый пользователь тенанта, детали email+codeHash).
/// </remarks>
public static class JoinEndpoint
{
// Путь ручки (вне группы /api/operator — публичная).
private const string JoinPath = "/api/join";
// OpenAPI-тег.
private const string JoinOpenApiTag = "join";
// Текст 400: приглашение с таким кодом не найдено.
private const string InviteNotFoundDetail = "Приглашение не найдено";
// Текст 400: срок действия приглашения истёк (план Task 6, Ruling 2).
private const string InviteExpiredDetail = "Срок действия приглашения истёк";
// Текст 400: приглашение уже активировано (повторная активация тем же кодом).
private const string InviteUsedDetail = "Приглашение уже использовано";
// Текст 400: приглашение отозвано оператором.
private const string InviteRevokedDetail = "Приглашение отозвано";
// Текст 400: email запроса не совпадает с email приглашения (Ruling 2).
private const string EmailMismatchDetail = "Email не совпадает с приглашением";
// Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, Ruling 2).
private const string EmailTakenDetail = "Этот email уже зарегистрирован";
// Текст 400: пароль короче минимума (текст как в AuthEndpoints, план Task 6).
private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
// Текст 400: целевой тенант инвайта не существует (Security review).
private const string TenantNotFoundDetail = "Тенант приглашения не найден";
// Текст 400: целевой тенант инвайта приостановлен (Security review).
private const string TenantSuspendedDetail = "Тенант приглашения приостановлен";
/// <summary>
/// Регистрирует POST /api/join.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapJoinEndpoint(this IEndpointRouteBuilder app)
{
app.MapPost(JoinPath, JoinAsync).WithTags(JoinOpenApiTag);
return app;
}
// POST /api/join: активация инвайта; успех пишется в аудит (invite_activated, Task 6/Ruling 4).
private static async Task<IResult> JoinAsync(
JoinRequest body,
JoinService joinService,
AuditService auditService,
HttpContext context,
CancellationToken ct)
{
var result = await joinService.ActivateAsync(body.Code, body.Email, body.Name, body.Password, ct);
if (!result.Ok || result.Login is null || result.UserId is null || result.TenantId is null)
{
return EndpointResults.BadRequest(DetailFor(result.Error));
}
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.InviteJoined,
AuditActorTypes.Tenant,
ActorId: result.UserId,
TenantId: result.TenantId,
Ip: ClientIp(context),
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
DetailJson: AuditService.ToDetailJson(new { email = result.Login, codeHash = SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty) })), ct);
return Results.Ok(new { ok = true, login = result.Login });
}
// Фиксированный текст 400 по коду ошибки JoinService (все отказы активации — 400).
// error: Код ошибки JoinResultDto.
// Возвращает: Текст детали ошибки.
private static string DetailFor(string? error) =>
error switch
{
JoinResultDto.ErrorExpired => InviteExpiredDetail,
JoinResultDto.ErrorUsed => InviteUsedDetail,
JoinResultDto.ErrorRevoked => InviteRevokedDetail,
JoinResultDto.ErrorEmailMismatch => EmailMismatchDetail,
JoinResultDto.ErrorEmailTaken => EmailTakenDetail,
JoinResultDto.ErrorPasswordTooShort => PasswordTooShortDetail,
JoinResultDto.ErrorTenantNotFound => TenantNotFoundDetail,
JoinResultDto.ErrorTenantSuspended => TenantSuspendedDetail,
_ => InviteNotFoundDetail,
};
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
}
@@ -0,0 +1,10 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/join — активация инвайта (Ruling 2, Task 6 этапа 7). Входящий JSON — camelCase (code, email, name?, password).
/// </summary>
/// <param name="Code">Код приглашения (16 url-safe символов).</param>
/// <param name="Email">Email активирующего; обязан совпасть с email приглашения (нормализует JoinService).</param>
/// <param name="Name">Имя нового тенанта (только когда у инвайта нет целевого тенанта); null — имя = email.</param>
/// <param name="Password">Пароль пользователя (минимум 8 символов).</param>
public sealed record JoinRequest(string? Code, string? Email, string? Name, string? Password);
@@ -0,0 +1,8 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/auth/login. Входящий JSON — camelCase (login, password).
/// </summary>
/// <param name="Login">Логин пользователя.</param>
/// <param name="Password">Пароль в открытом виде.</param>
public sealed record LoginRequest(string Login, string Password);
@@ -0,0 +1,9 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/ml/apply. Входящий JSON — camelCase (dialogId, msgId, action).
/// </summary>
/// <param name="DialogId">Id диалога/канала Telegram, где лежит исходное сообщение.</param>
/// <param name="MsgId">Id сообщения внутри диалога.</param>
/// <param name="Action">Ручное решение: spam | board:&lt;id&gt; | skip (api-map §3.7 L197).</param>
public sealed record MlApplyRequest(string DialogId, int MsgId, string Action);
@@ -0,0 +1,8 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/ml/candidates. Входящий JSON — camelCase (dialogId, limit).
/// </summary>
/// <param name="DialogId">Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта (§8).</param>
/// <param name="Limit">Сколько последних сообщений вернуть (кламп 1..60, дефолт 10).</param>
public sealed record MlCandidatesRequest(string DialogId, int Limit);
+182
View File
@@ -0,0 +1,182 @@
using Deal.Api.Http;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Pipeline.Application;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты ML-панели: GET /api/ml/status, POST /api/ml/reset, /predict, /candidates, /apply (Ruling 8, api-map §3.7).
/// </summary>
/// <remarks>
/// Тела ответов 1:1 с прототипом <c>backend/app/routers/ml_routes.py</c> (L6691, L112171):
/// <c>status</c> — MlStatusResponseDto (enabled/service/reachable/stats, §4.10 L363); <c>reset</c> —
/// <c>{ok:true}</c> (мягкая ошибка {ok:false,error} зарезервирована — заглушка всегда успешна);
/// <c>predict</c> — <c>{text: первые 200, take, label, scores, hits, ready, margin, terms, type}</c>
/// (текст короче 2 символов после trim → 400 «Введите текст»); <c>candidates</c> — <c>{items}</c> реальных
/// сообщений-кандидатов канала/выборки (очередь/отсев/карточки + мнение ML, §8; MlReviewService);
/// <c>apply</c> — 404 «Исходное сообщение не найдено» либо результат ручной разметки
/// <c>{ok, learned, moved, leadId}</c> (обучение ML + перенос/корзина/отсев). ml/learn и ml/flush
/// НЕ реализуются (фронт не вызывает, api-map п.9 L399). Все эндпоинты требуют сессию: 401 {detail}
/// (Ruling 10). IMlClient/MlReviewService резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped
/// на tenant-запрос — вне него не разрешимы, паттерн SettingsEndpoints/AiCheckEndpoint).
/// </remarks>
public static class MlEndpoints
{
// Префикс группы /api/ml (Ruling 8: MapMlEndpoints).
private const string MlGroupPrefix = "/api/ml";
// OpenAPI-тег группы (в прототипе роутер ml — ml_routes.py).
private const string MlOpenApiTag = "ml";
// Путь статуса ML (GET).
private const string StatusPath = "/status";
// Путь сброса модели (POST).
private const string ResetPath = "/reset";
// Путь проверки ML на тексте (POST).
private const string PredictPath = "/predict";
// Путь разбора сообщений канала (POST).
private const string CandidatesPath = "/candidates";
// Путь ручного решения по сообщению (POST).
private const string ApplyPath = "/apply";
// Минимальная длина текста для проверки (ml_routes.py L87: len(text) &lt; 2 → 400).
private const int MinPredictTextLength = 2;
// Длина текста в ответе predict: первые 200 символов (ml_routes.py L90 text[:200]).
private const int PredictTextPreviewLength = 200;
// Сообщение 400 для слишком короткого текста (ml_routes.py L88, план Task 9 L348).
private const string EnterTextDetail = "Введите текст";
// Сообщение 404 apply: исходное сообщение не найдено (ml_routes.py L142, план Task 9 L352).
private const string MessageNotFoundDetail = "Исходное сообщение не найдено";
/// <summary>
/// Регистрирует группу /api/ml: status/reset/predict/candidates/apply.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapMlEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(MlGroupPrefix).WithTags(MlOpenApiTag);
group.MapGet(StatusPath, StatusAsync);
group.MapPost(ResetPath, ResetAsync);
group.MapPost(PredictPath, PredictAsync);
group.MapPost(CandidatesPath, CandidatesAsync);
group.MapPost(ApplyPath, ApplyAsync);
return app;
}
// GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L6675).
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
return Results.Ok(await mlClient.StatusAsync(ct));
}
// POST /api/ml/reset: полный сброс модели + очистка очереди обучения (ml_routes.py L7881).
private static async Task<IResult> ResetAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
return Results.Ok(await mlClient.ResetAsync(ct));
}
// POST /api/ml/predict: проверка ML на тексте (ml_routes.py L8490).
private static async Task<IResult> PredictAsync(MlPredictRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
string text = (body.Text ?? string.Empty).Trim();
if (text.Length < MinPredictTextLength)
{
return EndpointResults.BadRequest(EnterTextDetail);
}
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
MlPredictResultDto result = await mlClient.PredictAsync(text, ct);
// Ответ 1:1 с ml_routes.py L90: {"text": <первые 200>, **результат предсказания}.
string preview = text.Length <= PredictTextPreviewLength
? text
: text[..PredictTextPreviewLength];
return Results.Ok(new
{
text = preview,
take = result.Take,
label = result.Label,
scores = result.Scores,
hits = result.Hits,
ready = result.Ready,
margin = result.Margin,
terms = result.Terms,
type = result.Type,
});
}
// POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112134).
private static async Task<IResult> CandidatesAsync(MlCandidatesRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
IReadOnlyList<MlCandidateDto> items = await review.CandidatesAsync(body.DialogId, body.Limit, ct);
return Results.Ok(new { items });
}
// POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137171).
private static async Task<IResult> ApplyAsync(MlApplyRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
MlApplyResult? result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct);
if (result is null)
{
return EndpointResults.NotFound(MessageNotFoundDetail);
}
if (result.Error is not null)
{
return EndpointResults.BadRequest(result.Error);
}
return Results.Ok(new
{
ok = result.Ok,
learned = result.Learned,
moved = result.Moved,
leadId = result.LeadId,
});
}
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
}
@@ -0,0 +1,7 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/ml/predict. Входящий JSON — camelCase (text).
/// </summary>
/// <param name="Text">Текст сообщения для проверки ML (обрезается/тримится обработчиком, как ml_routes.py L86).</param>
public sealed record MlPredictRequest(string Text);
@@ -0,0 +1,168 @@
using Deal.Api.Http;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3).
/// </summary>
/// <remarks>
/// Только под операторской сессией: без неё 401 «Требуется вход оператора» (как прочие /api/operator/*).
/// Ничего не меняет (read-only). groupBy — day|tenant|provider|model (неизвестное — 400 {detail}); from/to —
/// ISO-8601 (включительно), как у аудита; activity поддерживает фильтры eventType/actorType/actorId/tenantId,
/// limit (1..500) и offset. Все ответы — camelCase (контракт: docs/architecture/2026-09-10-operator-analytics-contract.md).
/// </remarks>
public static class OperatorAnalyticsEndpoints
{
// Префикс группы аналитики (Ruling 4 этапа 10).
private const string AnalyticsGroupPrefix = "/api/operator/analytics";
// OpenAPI-тег группы.
private const string OperatorOpenApiTag = "operator";
// Группировка расхода токенов по умолчанию (сутки).
private const string DefaultGroupBy = TokenUsageGroupBys.Day;
// 400 tokens: неизвестная группировка.
private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)";
/// <summary>
/// Регистрирует группу /api/operator/analytics: overview/tokens/activity.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapOperatorAnalyticsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(AnalyticsGroupPrefix).WithTags(OperatorOpenApiTag);
group.MapGet("/overview", OverviewAsync);
group.MapGet("/tokens", TokensAsync);
group.MapGet("/activity", ActivityAsync);
group.MapGet("/suspicious", SuspiciousAsync);
return app;
}
// GET /api/operator/analytics/suspicious?from=&amp;to=: находки детектора подозрительной активности (§10.5).
// from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа.
// to: Конец окна анализа (включительно; ISO-8601); null — «сейчас».
// context: Контекст запроса.
// suspiciousService: Детектор подозрительной активности (scoped).
// ct: Токен отмены.
// Возвращает: 200 сводка находок или 401 без операторской сессии.
private static async Task<IResult> SuspiciousAsync(
DateTimeOffset? from,
DateTimeOffset? to,
HttpContext context,
SuspiciousActivityService suspiciousService,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
SuspiciousActivityDto report = await suspiciousService.AnalyzeAsync(from, to, ct);
return Results.Ok(report);
}
// GET /api/operator/analytics/overview?from=&amp;to=: сводка (тенанты, токены, события, входы/выходы).
// from: Начало периода (включительно; ISO-8601).
// to: Конец периода (включительно; ISO-8601).
// context: Контекст запроса.
// analyticsService: Сервис аналитики (scoped).
// ct: Токен отмены.
// Возвращает: 200 сводка или 401 без операторской сессии.
private static async Task<IResult> OverviewAsync(
DateTimeOffset? from,
DateTimeOffset? to,
HttpContext context,
AnalyticsService analyticsService,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
AnalyticsOverviewDto overview = await analyticsService.OverviewAsync(from, to, ct);
return Results.Ok(overview);
}
// GET /api/operator/analytics/tokens?groupBy=&amp;tenantId=&amp;from=&amp;to=: агрегаты расхода токенов.
// groupBy: Группировка day|tenant|provider|model (дефолт day).
// tenantId: Тенант (равенство; пусто — все тенанты).
// from: Начало периода (включительно; ISO-8601).
// to: Конец периода (включительно; ISO-8601).
// context: Контекст запроса.
// analyticsService: Сервис аналитики (scoped).
// ct: Токен отмены.
// Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии.
private static async Task<IResult> TokensAsync(
string? groupBy,
Guid? tenantId,
DateTimeOffset? from,
DateTimeOffset? to,
HttpContext context,
AnalyticsService analyticsService,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
string normalizedGroupBy = string.IsNullOrWhiteSpace(groupBy) ? DefaultGroupBy : groupBy;
if (!IsKnownGroupBy(normalizedGroupBy))
{
return EndpointResults.BadRequest(InvalidGroupByDetail);
}
AnalyticsTokensDto tokens = await analyticsService.TokensAsync(normalizedGroupBy, tenantId, from, to, ct);
return Results.Ok(tokens);
}
// GET /api/operator/analytics/activity?eventType=&amp;actorType=&amp;actorId=&amp;tenantId=&amp;from=&amp;to=&amp;limit=&amp;offset=: лента действий.
// eventType: Тип события (равенство; пусто — без фильтра).
// actorType: Тип актора operator|tenant|system (равенство).
// actorId: Идентификатор актора (равенство).
// tenantId: Тенант (равенство).
// from: Нижняя граница At (включительно; ISO-8601).
// to: Верхняя граница At (включительно; ISO-8601).
// limit: Размер страницы (дефолт 100, кламп 1..500).
// offset: Смещение страницы (≥0).
// context: Контекст запроса.
// analyticsService: Сервис аналитики (scoped).
// ct: Токен отмены.
// Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии.
private static async Task<IResult> ActivityAsync(
string? eventType,
string? actorType,
Guid? actorId,
Guid? tenantId,
DateTimeOffset? from,
DateTimeOffset? to,
int? limit,
int? offset,
HttpContext context,
AnalyticsService analyticsService,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
return Results.Ok(activity);
}
/// <summary>
/// Известна ли группировка расхода токенов (day|tenant|provider|model).
/// </summary>
/// <param name="groupBy">Значение группировки.</param>
/// <returns>True — поддерживаемая группировка.</returns>
public static bool IsKnownGroupBy(string groupBy) =>
groupBy is TokenUsageGroupBys.Day or TokenUsageGroupBys.Tenant
or TokenUsageGroupBys.Provider or TokenUsageGroupBys.Model;
}
@@ -0,0 +1,94 @@
using Deal.Api.Http;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторский эндпоинт чтения аудита: GET /api/operator/audit (Ruling 4 этапа 7).
/// </summary>
/// <remarks>
/// Чтение — только оператору: без операторской сессии 401 «Требуется вход оператора» (как /api/operator/auth/me).
/// Фильтры-query: eventType, actorType, tenantId, from, to, limit (At DESC, limit клампится в
/// 1..<see cref="AuditService.MaxQueryLimit"/>, дефолт — <see cref="AuditService.DefaultQueryLimit"/>).
/// Ответ — {items: [...], total}: total — полное число записей по фильтру (без учёта limit). Запись событий —
/// только через <see cref="AuditService"/> (append-only); этот эндпоинт лишь читает.
/// </remarks>
public static class OperatorAuditEndpoints
{
// Префикс группы операторских ручек /api/operator (Ruling 11).
private const string OperatorGroupPrefix = "/api/operator";
// Путь ленты аудита относительно группы.
private const string AuditPath = "/audit";
// OpenAPI-тег группы (Ruling 11: операторская админка — API-only).
private const string OperatorOpenApiTag = "operator";
/// <summary>
/// Регистрирует группу /api/operator: GET /audit (лента аудита; другие ручки — задачи 5/7/10).
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapOperatorAuditEndpoints(this IEndpointRouteBuilder app)
{
app.MapGroup(OperatorGroupPrefix).WithTags(OperatorOpenApiTag).MapGet(AuditPath, ListAsync);
return app;
}
// GET /api/operator/audit?eventType=&amp;actorType=&amp;actorId=&amp;tenantId=&amp;from=&amp;to=&amp;limit=&amp;offset=: лента аудита.
// eventType: Фильтр по типу события (равенство; пусто — без фильтра).
// actorType: Фильтр по типу актора operator|tenant|system (равенство).
// actorId: Фильтр по идентификатору актора (равенство).
// tenantId: Фильтр по тенанту (равенство).
// from: Нижняя граница At (включительно; ISO-8601).
// to: Верхняя граница At (включительно; ISO-8601).
// limit: Размер выборки (дефолт 100, клампится 1..500).
// offset: Смещение страницы (≥0; этап 10, T3).
// context: Контекст запроса.
// auditService: Сервис аудита (scoped).
// ct: Токен отмены.
// Возвращает: 200 {items:[...], total} или 401 без операторской сессии.
private static async Task<IResult> ListAsync(
string? eventType,
string? actorType,
Guid? actorId,
Guid? tenantId,
DateTimeOffset? from,
DateTimeOffset? to,
int? limit,
int? offset,
HttpContext context,
AuditService auditService,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
var filter = new AuditQueryDto(
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
int total = await auditService.CountAsync(filter, ct);
return Results.Ok(new { items, total });
}
/// <summary>
/// Нормализует limit запроса: дефолт <see cref="AuditService.DefaultQueryLimit"/>, кламп 1..500 (Ruling 4).
/// </summary>
/// <param name="limit">Запрошенный размер выборки (null — не задан).</param>
/// <returns>Значение для фильтра.</returns>
public static int NormalizeLimit(int? limit) =>
limit is null
? AuditService.DefaultQueryLimit
: Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit.Value));
/// <summary>
/// Нормализует offset запроса: отрицательное/отсутствующее — 0 (этап 10, T3).
/// </summary>
/// <param name="offset">Запрошенное смещение (null — не задано).</param>
/// <returns>Неотрицательное смещение.</returns>
public static int NormalizeOffset(int? offset) => Math.Max(0, offset ?? 0);
}
@@ -0,0 +1,171 @@
using Deal.Api.Http;
using Deal.Api.Middleware;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
using Microsoft.Extensions.Options;
using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами.
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
namespace Deal.Api.Endpoints;
/// <summary>
/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1).
/// </summary>
/// <remarks>
/// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore),
/// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax).
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10). Защищённые
/// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит.
/// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4).
/// </remarks>
public static class OperatorAuthEndpoints
{
private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
private const string OperatorAuthGroupPrefix = "/api/operator/auth";
private const string OperatorAuthOpenApiTag = "operator-auth";
/// <summary>
/// Регистрирует группу /api/operator/auth: login, logout, me.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag);
// Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки
// входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
group.MapPost("/logout", LogoutAsync);
group.MapGet("/me", MeAsync);
return app;
}
// POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4).
// До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
private static async Task<IResult> LoginAsync(
LoginRequest body,
OperatorAuthService operatorAuthService,
AuditService auditService,
IOptions<OperatorCookieOptions> cookieOptions,
HttpContext context,
CancellationToken ct,
LoginAttemptGuard loginAttemptGuard)
{
string? attemptedLogin = NormalizeLogin(body.Login);
// Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа
// ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен).
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
{
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
}
var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct);
if (result.Login is null || result.Token is null)
{
// Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints).
// Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4);
// счётчик неудач гварда растёт там же (пустые логины ключа не имеют).
if (attemptedLogin is not null)
{
await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.OperatorLoginFailed,
AuditActorTypes.Operator,
ActorId: null,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
}
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
}
// Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5).
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.OperatorLoginOk,
AuditActorTypes.Operator,
ActorId: result.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
return Results.Ok(new { ok = true, login = result.Login });
}
// POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok).
private static async Task<IResult> LogoutAsync(
OperatorAuthService operatorAuthService,
IOptions<OperatorCookieOptions> cookieOptions,
HttpContext context,
CancellationToken ct)
{
var cookieName = cookieOptions.Value.Name;
var rawToken = context.Request.Cookies[cookieName];
// Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items).
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
await operatorAuthService.LogoutAsync(rawToken, ct);
context.Response.Cookies.Delete(cookieName);
// Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии.
if (operatorIdentity is not null)
{
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
}
return Results.Ok(new { ok = true });
}
// GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1).
private static IResult MeAsync(HttpContext context)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
return Results.Ok(new { login = operatorIdentity.Login, ok = true });
}
// Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига.
// context: Контекст запроса.
// options: Настройки куки из конфигурации (секция OperatorCookies).
// rawToken: Raw-токен операторской сессии.
private static void SetOperatorSessionCookie(HttpContext context, OperatorCookieOptions options, string rawToken)
{
// MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на
// OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions).
context.Response.Cookies.Append(
options.Name,
rawToken,
new AspNetCoreCookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
MaxAge = TimeSpan.FromHours(options.Hours),
Secure = options.Secure,
});
}
// Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего.
// login: Логин из тела запроса.
// Возвращает: Нормализованный логин или null при пустом/пробельном входе.
private static string? NormalizeLogin(string? login)
{
string? normalized = login?.Trim().ToLowerInvariant();
return string.IsNullOrEmpty(normalized) ? null : normalized;
}
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
}
@@ -0,0 +1,195 @@
using Deal.Api.Http;
using Deal.Api.Observability;
using Deal.Infrastructure.Integrations;
using Deal.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторский health: GET /api/operator/health (план Task 10, Ruling 3/6/9/11) — ядро/БД и
/// автономные сервисы ml/ai/telegram.
/// </summary>
/// <remarks>
/// Ручка — только оператору (401 «Требуется вход оператора» без операторской сессии). Ответ всегда 200
/// (информационный операторский обзор, как /api/health) с полями состояния:
/// <c>{ok, core:{db:"ok"|"down"}, services:[{name, mode:"grpc"|"local", status, reachable}],
/// queues:{pipeline, mlOutbox}, sessions:{active}}</c>. Глубины очередей обработки/ML-outbox и число
/// активных сессий (§10.2) собирает общий <see cref="RuntimeDepthsCollector"/> (тот же путь, что метрики).
/// Проверка БД — <c>SELECT 1</c> через DealDbContext (public-схема) с таймаутом 5 с; сбой (контейнер не поднят/
/// сеть) → core.db=down без падения ручки. Сервисы: при <c>Services:*:UseLocal=true</c> — <c>{mode:"local",
/// reachable:false, status:"local"}</c> (Local-адаптеры, реальный сервис не поднят — Ruling 6; dev-приёмка);
/// в gRPC-режиме — <see cref="ServiceHealthProbe"/> к <c>Services:*:Endpoint</c> (grpc.health.v1, таймаут 3 с):
/// SERVING → status=ok, иной статус → unhealthy, недоступен → down. <c>ok</c> сводки — БД доступна и все
/// сервисы в порядке (Local-режим не считается сбоем).
/// </remarks>
public static class OperatorHealthEndpoints
{
// Префикс группы операторских ручек health (Ruling 11).
private const string OperatorGroupPrefix = "/api/operator";
// Путь health-ручки.
private const string HealthPath = "/health";
// OpenAPI-тег группы.
private const string OperatorOpenApiTag = "operator-health";
// Статус БД/сервиса: доступна/здоров (ok).
private const string StatusOk = "ok";
// Статус сервиса: БД/сервис недоступен (down).
private const string StatusDown = "down";
// Статус сервиса: ответил, но не SERVING (grpc NOT_SERVING/SERVICE_UNKNOWN).
private const string StatusUnhealthy = "unhealthy";
// Статус сервиса в Local-режиме: реальный сервис не подключён (UseLocal=true, Ruling 6).
private const string StatusLocal = "local";
// Режим сервиса: Local-адаптеры (UseLocal=true).
private const string ModeLocal = "local";
// Режим сервиса: gRPC-клиент (UseLocal=false).
private const string ModeGrpc = "grpc";
// Имя ml-service в ответе (порядок секций — как в стартовых логах Program.cs).
private const string MlServiceName = "ml";
// Имя ai-service в ответе.
private const string AiServiceName = "ai";
// Имя telegram-service в ответе.
private const string TelegramServiceName = "telegram";
// Таймаут проверки БД, миллисекунд (health не должен висеть на мёртвом хосте Postgres).
private const int DatabaseProbeTimeoutMilliseconds = 5000;
/// <summary>
/// Регистрирует GET /api/operator/health (health ядра/БД и автономных сервисов).
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapOperatorHealthEndpoints(this IEndpointRouteBuilder app)
{
app.MapGroup(OperatorGroupPrefix).WithTags(OperatorOpenApiTag).MapGet(HealthPath, GetAsync);
return app;
}
// GET /api/operator/health: {ok, core:{db}, services:[{name,mode,status,reachable}]} (всегда 200).
private static async Task<IResult> GetAsync(
HttpContext context,
DealDbContext dbContext,
ServiceHealthProbe healthProbe,
RuntimeDepthsCollector depthsCollector,
MlServiceOptions mlOptions,
AiServiceOptions aiOptions,
TelegramServiceOptions telegramOptions,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
// Параллельно: БД (SELECT 1), health-пробы сервисов (до 3 с на сервис) и снимок глубин очередей/сессий.
Task<string> databaseTask = ProbeDatabaseAsync(dbContext, ct);
Task<List<ServiceEntryDto>> servicesTask = ProbeServicesAsync(healthProbe, mlOptions, aiOptions, telegramOptions, ct);
Task<RuntimeDepthsDto> depthsTask = depthsCollector.CollectAsync(ct);
await Task.WhenAll(databaseTask, servicesTask, depthsTask);
string databaseStatus = await databaseTask;
List<ServiceEntryDto> services = await servicesTask;
RuntimeDepthsDto depths = await depthsTask;
bool ok = databaseStatus == StatusOk
&& services.All(service => service.Mode == ModeLocal || service.Status == StatusOk);
return Results.Ok(new
{
ok,
core = new { db = databaseStatus },
services,
queues = new { pipeline = depths.PipelineQueue, mlOutbox = depths.MlOutbox },
sessions = new { active = depths.ActiveSessions },
});
}
// Проверяет доступность БД core: SELECT 1 через DealDbContext (public-схема) с таймаутом 5 с.
// dbContext: Системный контекст.
// ct: Токен отмены.
// Возвращает: Статус БД: ok/down (сбой не роняет ручку).
private static async Task<string> ProbeDatabaseAsync(DealDbContext dbContext, CancellationToken ct)
{
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(DatabaseProbeTimeoutMilliseconds);
await dbContext.Database.ExecuteSqlRawAsync("SELECT 1", timeout.Token);
return StatusOk;
}
catch (Exception)
{
// Postgres недоступен (контейнер не поднят/сеть) — оператор видит core.db=down, ручка жива.
return StatusDown;
}
}
// Пробы сервисов ml/ai/telegram по их конфигурации: Local-режим — пометка local без вызова;
// gRPC-режим — health-проба к Services:*:Endpoint (ServiceHealthProbe).
// healthProbe: Проба grpc.health.v1.
// mlOptions: Конфигурация ml-service.
// aiOptions: Конфигурация ai-service.
// telegramOptions: Конфигурация telegram-service.
// ct: Токен отмены.
// Возвращает: Записи состояния сервисов в порядке ml → ai → telegram.
private static async Task<List<ServiceEntryDto>> ProbeServicesAsync(
ServiceHealthProbe healthProbe,
MlServiceOptions mlOptions,
AiServiceOptions aiOptions,
TelegramServiceOptions telegramOptions,
CancellationToken ct)
{
var services = new List<ServiceEntryDto>(capacity: 3);
await ProbeServiceAsync(services, healthProbe, MlServiceName, mlOptions.UseLocal, mlOptions.Endpoint, ct);
await ProbeServiceAsync(services, healthProbe, AiServiceName, aiOptions.UseLocal, aiOptions.Endpoint, ct);
await ProbeServiceAsync(services, healthProbe, TelegramServiceName, telegramOptions.UseLocal, telegramOptions.Endpoint, ct);
return services;
}
// Одна запись состояния сервиса в списке services (см. ProbeServicesAsync).
// services: Куда добавить запись.
// healthProbe: Проба grpc.health.v1.
// name: Имя сервиса в ответе.
// useLocal: True — Local-режим (UseLocal=true): реальный сервис не подключён.
// endpoint: Базовый адрес сервиса (для gRPC-режима).
// ct: Токен отмены.
private static async Task ProbeServiceAsync(
List<ServiceEntryDto> services,
ServiceHealthProbe healthProbe,
string name,
bool useLocal,
string endpoint,
CancellationToken ct)
{
if (useLocal)
{
services.Add(new ServiceEntryDto(name, ModeLocal, StatusLocal, Reachable: false));
return;
}
ServiceHealthResult result = await healthProbe.ProbeAsync(endpoint, ct);
string status = (result.Reachable, result.Serving) switch
{
(true, true) => StatusOk,
(true, false) => StatusUnhealthy,
_ => StatusDown,
};
services.Add(new ServiceEntryDto(name, ModeGrpc, status, result.Reachable));
}
// Запись состояния сервиса в ответе /api/operator/health (приватная форма сериализации).
// Name: Имя сервиса (ml/ai/telegram).
// Mode: Режим: local (UseLocal=true) | grpc (UseLocal=false).
// Status: Состояние: ok | unhealthy | down (в Local-режиме — local).
// Reachable: True — сервис ответил на health-пробу (в Local-режиме всегда false).
private sealed record ServiceEntryDto(string Name, string Mode, string Status, bool Reachable);
}
@@ -0,0 +1,8 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/operator/invites: email приглашённого и опциональный целевой тенант (Ruling 2 этапа 7).
/// </summary>
/// <param name="Email">Email приглашённого (регистр/пробелы не важны — нормализует InvitesService).</param>
/// <param name="TenantId">Целевой тенант; null — при активации будет создан новый тенант (Task 6).</param>
public sealed record OperatorInviteCreateRequest(string? Email, Guid? TenantId);
@@ -0,0 +1,146 @@
using Deal.Api.Http;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторские эндпоинты приглашений: GET /api/operator/invites, POST (создание), POST {code}/revoke (Ruling 2/11 этапа 7).
/// </summary>
/// <remarks>
/// Создание/отзыв/чтение — только оператор: без операторской сессии 401 «Требуется вход оператора»
/// (как /api/operator/auth/me). Создание возвращает {code, email, tenantId, expiresAt, status} (план Task 5),
/// список — {items:[...]} (полные строки; форма как у GET /api/operator/audit), отзыв — {ok:true}. Результаты
/// пишутся в аудит — invite_created/invite_revoked с email и codeHash в DetailJson (Ruling 4; операторские события,
/// TenantId null; хэш кода — Security review). Тексты ошибок — фиксированные строки HTTP-слоя (паттерн AuthEndpoints).
/// </remarks>
public static class OperatorInvitesEndpoints
{
// Текст 400: email пустой/некорректного формата.
private const string InvalidEmailDetail = "Некорректный email";
// Текст 400: на email уже есть активное приглашение (план Task 5, Ruling 2).
private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение";
// Текст 404: приглашение с таким кодом не найдено.
private const string InviteNotFoundDetail = "Приглашение не найдено";
// Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло).
private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение";
// Префикс группы операторских ручек приглашений (Ruling 11).
private const string InvitesGroupPrefix = "/api/operator/invites";
// Относительный путь отзыва приглашения.
private const string RevokePath = "/{code}/revoke";
// OpenAPI-тег группы.
private const string InvitesOpenApiTag = "operator-invites";
/// <summary>
/// Регистрирует группу /api/operator/invites: GET (список), POST (создание), POST {code}/revoke (отзыв).
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapOperatorInvitesEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(InvitesGroupPrefix).WithTags(InvitesOpenApiTag);
group.MapGet("", ListAsync);
group.MapPost("", CreateAsync);
group.MapPost(RevokePath, RevokeAsync);
return app;
}
// GET /api/operator/invites: список приглашений (новые сверху, со статусами; expired проставляется лениво).
private static async Task<IResult> ListAsync(
HttpContext context,
InvitesService invitesService,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
IReadOnlyList<InviteDto> items = await invitesService.ListAsync(ct);
return Results.Ok(new { items });
}
// POST /api/operator/invites: создание приглашения; результат пишется в аудит (invite_created).
private static async Task<IResult> CreateAsync(
OperatorInviteCreateRequest body,
HttpContext context,
InvitesService invitesService,
AuditService auditService,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
InviteCreateResultDto result = await invitesService.CreateInviteAsync(
operatorIdentity.OperatorId, body.Email, body.TenantId, ct);
if (!result.Ok || result.Invite is null)
{
string detail = result.Error == InviteCreateResultDto.ErrorDuplicateActive
? DuplicateActiveDetail
: InvalidEmailDetail;
return EndpointResults.BadRequest(detail);
}
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.InviteCreated,
AuditActorTypes.Operator,
ActorId: operatorIdentity.OperatorId,
TenantId: null,
Ip: ClientIp(context),
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
DetailJson: AuditService.ToDetailJson(new { email = result.Invite.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
InviteDto invite = result.Invite;
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
}
// POST /api/operator/invites/{code}/revoke: отзыв ожидающего приглашения; результат пишется в аудит (invite_revoked).
private static async Task<IResult> RevokeAsync(
string code,
HttpContext context,
InvitesService invitesService,
AuditService auditService,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
InviteRevokeResultDto result = await invitesService.RevokeAsync(code, ct);
if (!result.Ok)
{
return result.Error == InviteRevokeResultDto.ErrorNotFound
? EndpointResults.NotFound(InviteNotFoundDetail)
: EndpointResults.BadRequest(InviteNotPendingDetail);
}
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.InviteRevoked,
AuditActorTypes.Operator,
ActorId: operatorIdentity.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { email = result.Invite!.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
return Results.Ok(new { ok = true });
}
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
}
@@ -0,0 +1,10 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело PATCH /api/operator/tenants/{id}/limit: смена лимитов ИИ-бюджета тенанта (план Task 10, Ruling 3).
/// Оба поля опциональны — меняется только заданное; смена бюджета/периода сбрасывает флаги Warned80/
/// NotifiedExhausted (новый период открывает пороги тостов, один тост на период на порог, Ruling 3).
/// </summary>
/// <param name="Budget">Новый бюджет периода в токенах (≥0; 0 — ИИ запрещён); null — оставить текущий.</param>
/// <param name="Period">Новый тип периода (константа <c>TenantLimitPeriods</c>: month|day); null — оставить текущий.</param>
public sealed record OperatorLimitUpdateRequest(long? Budget, string? Period);
@@ -0,0 +1,245 @@
using Deal.Api.Http;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторские эндпоинты лимитов ИИ-бюджета: сводка по всем тенантам и просмотр/смена лимита тенанта
/// (план Task 10, Ruling 3/11).
/// </summary>
/// <remarks>
/// Все ручки — только оператору: без операторской сессии 401 «Требуется вход оператора» (как остальные
/// /api/operator/*). GET /api/operator/limits — сводка {items:[{tenantId, name, budget, period, used, percent,
/// status}]} по реестру тенантов (Ruling 3: строка лимита на путь чтения заводится лениво с дефолт-бюджетом —
/// тенант без расхода виден как «дефолт, 0»). GET/PATCH /api/operator/tenants/{id}/limit — детали/смена лимита:
/// PATCH принимает {budget?, period?} (оба опциональны — меняется только заданное; null-тело/без полей → 400),
/// сбрасывает Warned80/NotifiedExhausted через UpdateBudgetAsync (Ruling 3: смена бюджета открывает пороги
/// тостов заново) и пишет аудит tenant_limit_changed (только при реальном изменении — повторный PATCH с теми же
/// значениями идемпотентен, аудит не дублируется). Отрицательный бюджет/чужой период отсекаются 400 до вызова
/// хранилища; тенант проверяется по реестру (404 «Тенант не найден»). Ответы деталей — единая форма
/// (см. <see cref="BuildDetailDto"/>) — статус тенанта, флаги порогов и процент расхода.
/// </remarks>
public static class OperatorLimitsEndpoints
{
// Текст 400: PATCH без полей (null-тело/пустой объект).
private const string EmptyUpdateDetail = "Укажите новый бюджет или период";
// Текст 400: бюджет отрицательный (порог лимита не позволяет).
private const string NegativeBudgetDetail = "Бюджет должен быть неотрицательным";
// Текст 400: период не month и не day (константы TenantLimitPeriods).
private const string InvalidPeriodDetail = "Период должен быть month или day";
// Текст 404: тенант с таким id не найден в реестре.
private const string TenantNotFoundDetail = "Тенант не найден";
// Верхняя граница процента расхода (диапазон 0..100) — константа расчёта CalculatePercent.
private const int PercentMax = 100;
// Префикс сводки лимитов (Ruling 11: /api/operator/*).
private const string OperatorGroupPrefix = "/api/operator";
// Префикс группы операторских ручек тенантов (общий с Task 7).
private const string TenantsGroupPrefix = "/api/operator/tenants";
// Путь сводки лимитов по всем тенантам.
private const string SummaryPath = "/limits";
// Относительный путь лимита тенанта (просмотр/смена).
private const string TenantLimitPath = "/{id:guid}/limit";
// OpenAPI-тег группы сводки лимитов.
private const string LimitsOpenApiTag = "operator-limits";
// Без состояния, поэтому безопасен как статический экземпляр (период-математика Task 8).
private static readonly TokenBudgetService BudgetService = new();
/// <summary>
/// Регистрирует ручки лимитов: GET /api/operator/limits (сводка) и GET/PATCH
/// /api/operator/tenants/{id}/limit (детали/смена).
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapOperatorLimitsEndpoints(this IEndpointRouteBuilder app)
{
app.MapGroup(OperatorGroupPrefix).WithTags(LimitsOpenApiTag).MapGet(SummaryPath, ListSummaryAsync);
var tenantsGroup = app.MapGroup(TenantsGroupPrefix).WithTags(LimitsOpenApiTag);
tenantsGroup.MapGet(TenantLimitPath, GetLimitAsync);
tenantsGroup.MapPatch(TenantLimitPath, PatchLimitAsync);
return app;
}
// GET /api/operator/limits: сводка бюджета/расхода по всем тенантам (план Task 10).
private static async Task<IResult> ListSummaryAsync(
HttpContext context,
ITenantRepository tenantRepository,
ITenantLimitStore limitStore,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
var items = new List<object>(tenants.Count);
foreach (TenantRecordDto tenant in tenants)
{
// Ленивый reset периода внутри GetStateAsync (Ruling 3): сводка всегда про текущий период.
BudgetStateDto state = await limitStore.GetStateAsync(tenant.Id, ct);
items.Add(new
{
tenantId = tenant.Id,
name = tenant.Name,
budget = state.BudgetTokens,
period = state.Period,
used = state.UsedTokens,
percent = CalculatePercent(state.UsedTokens, state.BudgetTokens),
status = state.Status,
});
}
return Results.Ok(new { items });
}
// GET /api/operator/tenants/{id}/limit: детали лимита тенанта (форма BuildDetailDto).
private static async Task<IResult> GetLimitAsync(
Guid id,
HttpContext context,
ITenantRepository tenantRepository,
ITenantLimitStore limitStore,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
TenantRecordDto? tenant = await tenantRepository.FindByIdAsync(id, ct);
if (tenant is null)
{
return EndpointResults.NotFound(TenantNotFoundDetail);
}
BudgetStateDto state = await limitStore.GetStateAsync(id, ct);
return Results.Ok(BuildDetailDto(tenant.Name, state));
}
// PATCH /api/operator/tenants/{id}/limit: смена бюджета/периода (сброс флагов + аудит tenant_limit_changed).
private static async Task<IResult> PatchLimitAsync(
Guid id,
OperatorLimitUpdateRequest? body,
HttpContext context,
ITenantRepository tenantRepository,
ITenantLimitStore limitStore,
AuditService auditService,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
if (body is null || (body.Budget is null && string.IsNullOrWhiteSpace(body.Period)))
{
return EndpointResults.BadRequest(EmptyUpdateDetail);
}
if (body.Budget is < 0)
{
return EndpointResults.BadRequest(NegativeBudgetDetail);
}
if (body.Period is not null
&& body.Period != TenantLimitPeriods.Month
&& body.Period != TenantLimitPeriods.Day)
{
return EndpointResults.BadRequest(InvalidPeriodDetail);
}
TenantRecordDto? tenant = await tenantRepository.FindByIdAsync(id, ct);
if (tenant is null)
{
return EndpointResults.NotFound(TenantNotFoundDetail);
}
// Текущее состояние — источник значений не заданных в PATCH полей (период/бюджет меняются по отдельности).
BudgetStateDto current = await limitStore.GetStateAsync(id, ct);
long newBudget = body.Budget ?? current.BudgetTokens;
string newPeriod = body.Period ?? current.Period;
if (newBudget == current.BudgetTokens && newPeriod == current.Period)
{
// Идемпотентный повторный PATCH: без изменения хранилища и без дубля аудита.
return Results.Ok(BuildDetailDto(tenant.Name, current));
}
BudgetStateDto updated = await limitStore.UpdateBudgetAsync(id, newBudget, newPeriod, ct);
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.TenantLimitChanged,
AuditActorTypes.Operator,
ActorId: operatorIdentity.OperatorId,
TenantId: id,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new
{
tenantId = id,
oldBudget = current.BudgetTokens,
oldPeriod = current.Period,
budgetTokens = newBudget,
period = newPeriod,
})), ct);
return Results.Ok(BuildDetailDto(tenant.Name, updated));
}
/// <summary>
/// Процент расхода бюджета для операторской сводки/деталей (0..100, floor).
/// </summary>
/// <remarks>
/// Бюджет ≤0 трактуется как исчерпанный (лимит 0 запрещает ИИ, Ruling 3) → 100%; расход ≥ бюджета также
/// показывается как 100 (потолок индикатора). Расчёт — в double: диапазон long (до ~9.2·10¹⁸ токенов)
/// не переполняет double, floor-ошибка возможна только на границе целого при масштабах, нереальных для
/// бюджета токенов (целочисленный used·100/budget переполнялся бы при used &gt; ~9.2·10¹⁶).
/// </remarks>
/// <param name="usedTokens">Использовано токенов с начала периода.</param>
/// <param name="budgetTokens">Бюджет периода.</param>
/// <returns>Процент в диапазоне 0..100 (расход сверх бюджета показывается как 100).</returns>
public static int CalculatePercent(long usedTokens, long budgetTokens)
{
if (budgetTokens <= 0 || usedTokens >= budgetTokens)
{
return PercentMax;
}
return (int)(usedTokens * (double)PercentMax / budgetTokens);
}
// Форма деталей лимита тенанта (GET и ответ PATCH — единая).
// name: Имя тенанта (реестр).
// state: Состояние бюджета (после ленивого reset).
// Возвращает: Объект ответа: лимит + расход + флаги порогов + статус тенанта.
private static object BuildDetailDto(string name, BudgetStateDto state) => new
{
tenantId = state.TenantId,
name,
status = state.Status,
allowed = state.Allowed,
budget = state.BudgetTokens,
period = state.Period,
periodStart = state.PeriodStart,
used = state.UsedTokens,
remaining = BudgetService.RemainingTokens(state.UsedTokens, state.BudgetTokens),
percent = CalculatePercent(state.UsedTokens, state.BudgetTokens),
warned80 = state.Warned80,
notifiedExhausted = state.NotifiedExhausted,
};
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
}
@@ -0,0 +1,64 @@
using Deal.Api.Http;
using Deal.Infrastructure.Tenancy;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторские maintenance-ручки (этап 12, пакет C): пакетная миграция схем всех тенантов.
/// </summary>
/// <remarks>
/// Ручка — только оператору (401 «Требуется вход оператора» без операторской сессии).
/// POST /api/operator/maintenance/tenants/migrate — идемпотентно проводит провижининг/миграции схем ВСЕХ
/// тенантов реестра (CREATE SCHEMA IF NOT EXISTS + EF Migrate, применяющий только неприменённые миграции)
/// с ограниченным параллелизмом и логированием прогресса (<see cref="TenantSchemaMigrationService"/>).
/// Ответ <c>{ok, total, migrated, failed, failedSchemas, durationMs}</c>; ok=false, если хотя бы одна схема
/// не мигрирована (сбой одной не прерывает остальные — оператор видит список проблемных схем).
/// </remarks>
public static class OperatorMaintenanceEndpoints
{
// Префикс группы операторских maintenance-ручек.
private const string MaintenanceGroupPrefix = "/api/operator/maintenance";
// Относительный путь пакетной миграции схем тенантов.
private const string MigrateTenantsPath = "/tenants/migrate";
// OpenAPI-тег группы.
private const string MaintenanceOpenApiTag = "operator-maintenance";
/// <summary>
/// Регистрирует группу /api/operator/maintenance: пакетная миграция схем тенантов.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapOperatorMaintenanceEndpoints(this IEndpointRouteBuilder app)
{
app.MapGroup(MaintenanceGroupPrefix)
.WithTags(MaintenanceOpenApiTag)
.MapPost(MigrateTenantsPath, MigrateTenantsAsync);
return app;
}
// POST /api/operator/maintenance/tenants/migrate: миграция схем всех тенантов (сводка прогресса).
private static async Task<IResult> MigrateTenantsAsync(
HttpContext context,
TenantSchemaMigrationService migrationService,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
TenantMigrationSummary summary = await migrationService.MigrateAllAsync(ct);
return Results.Ok(new
{
ok = summary.Ok,
total = summary.Total,
migrated = summary.Migrated,
failed = summary.Failed,
failedSchemas = summary.FailedSchemas,
durationMs = summary.DurationMs,
});
}
}
@@ -0,0 +1,154 @@
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
using Deal.Api.Telegram;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram
/// (ТЗ §4.1/§8.1).
/// </summary>
/// <remarks>
/// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram
/// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт.
/// <list type="bullet">
/// <item>GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт),
/// apiHash (маска) и keysSet;</item>
/// <item>PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно
/// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой),
/// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях).</item>
/// </list>
/// Ошибки — 400/401 <c>{detail}</c> (формат прототипа, Ruling 10).
/// </remarks>
public static class OperatorSettingsEndpoints
{
// Префикс группы операторских настроек.
private const string SettingsGroupPrefix = "/api/operator/settings";
// OpenAPI-тег группы.
private const string SettingsOpenApiTag = "operator-settings";
// Относительный путь глобальных ключей Telegram (GET/PUT).
private const string TelegramKeysPath = "/telegram-keys";
// Текст 400: пустое тело PUT (ни одного поля).
private const string EmptyBodyDetail = "Укажите api_id и api_hash";
// Текст 400: частичное обновление, но ключей ещё нет — нужны оба поля.
private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash";
// Текст 400: api_id не 5..9 цифр.
private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр";
// Текст 400: api_hash пустой/маска/с префиксом enc:.
private const string InvalidApiHashDetail = "Укажите непустой api_hash";
/// <summary>
/// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT).
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapOperatorSettingsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag);
group.MapGet(TelegramKeysPath, GetTelegramKeysAsync);
group.MapPut(TelegramKeysPath, PutTelegramKeysAsync);
return app;
}
// GET /api/operator/settings/telegram-keys: маскированные глобальные ключи Telegram.
// context: Контекст запроса.
// keys: Сервис глобальных ключей Telegram (scoped).
// ct: Токен отмены.
// Возвращает: 200 маскированный снимок или 401 без операторской сессии.
private static async Task<IResult> GetTelegramKeysAsync(
HttpContext context,
TelegramKeysService keys,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
return Results.Ok(snapshot);
}
// PUT /api/operator/settings/telegram-keys: частичное сохранение глобальных ключей Telegram
// оператором.
// Поля можно передавать по отдельности: непереданное поле (null) сохраняет текущее значение,
// явное значение (в т.ч. пустая строка) валидируется. Если ключей ещё нет, оба поля обязательны.
// body: Тело {apiId?, apiHash?} (хотя бы одно поле).
// context: Контекст запроса.
// keys: Сервис глобальных ключей Telegram (scoped).
// auditService: Сервис аудита (событие telegram_keys_changed).
// ct: Токен отмены.
// Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии.
private static async Task<IResult> PutTelegramKeysAsync(
OperatorTelegramKeysRequest? body,
HttpContext context,
TelegramKeysService keys,
AuditService auditService,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
if (body is null)
{
return EndpointResults.BadRequest(EmptyBodyDetail);
}
// null — поле не передано (сохраняем текущее); непустая строка/плейсхолдер — валидируем явно.
string? apiId = body.ApiId?.Trim();
string? apiHash = body.ApiHash?.Trim();
if (apiId is null && apiHash is null)
{
return EndpointResults.BadRequest(EmptyBodyDetail);
}
if (apiId is not null && !TelegramKeysService.IsValidApiId(apiId))
{
return EndpointResults.BadRequest(InvalidApiIdDetail);
}
if (apiHash is not null && !TelegramKeysService.IsValidApiHash(apiHash))
{
return EndpointResults.BadRequest(InvalidApiHashDetail);
}
// Частичное обновление: недостающее поле берём из текущих ключей; если ключей ещё нет — нужны оба.
TgKeysSnapshot current = await keys.GetAsync(ct);
string effectiveApiId = apiId ?? current.ApiId;
string effectiveApiHash = apiHash ?? current.ApiHash;
if (effectiveApiId.Length == 0 || effectiveApiHash.Length == 0)
{
return EndpointResults.BadRequest(MissingKeysDetail);
}
await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct);
// Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4).
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.TelegramKeysChanged,
AuditActorTypes.Operator,
ActorId: operatorIdentity.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct);
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
return Results.Ok(snapshot);
}
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
}
@@ -0,0 +1,9 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/operator/tenants: создание тенанта оператором (план Task 7, Ruling 11).
/// </summary>
/// <param name="Name">Имя тенанта (обязательно; пробелы по краям обрезаются).</param>
/// <param name="Email">Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым
/// паролем (иначе владелец заводится инвайтом, Ruling 2).</param>
public sealed record OperatorTenantCreateRequest(string? Name, string? Email);
@@ -0,0 +1,8 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/operator/tenants/{id}/impersonate: опциональный логин пользователя тенанта (план Task 7).
/// </summary>
/// <param name="Login">Логин пользователя, под которым оператор входит (impersonation); null/пустой —
/// берётся первый пользователь тенанта (по времени создания).</param>
public sealed record OperatorTenantImpersonateRequest(string? Login);
@@ -0,0 +1,290 @@
using Deal.Api.Http;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
using Microsoft.Extensions.Options;
using CookieOptions = Deal.Api.Configuration.CookieOptions;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторские эндпоинты тенантов: create/список/детали, suspend/unsuspend, impersonation (план Task 7, Ruling 1/4/10/11).
/// </summary>
/// <remarks>
/// Все ручки — только оператору: без операторской сессии 401 «Требуется вход оператора» (как остальные
/// /api/operator/*). POST "" (create {name, email?}) — тенант (Status active) + провижининг схемы + аудит
/// tenant_created; список — {items:[...]} (реестр + счётчик пользователей; поля лимитов добавит Task 8),
/// детали — {id,name,status,createdAt,users:[...]}. Suspend/unsuspend меняют Status тенанта
/// (TenantAdminService) и пишут аудит tenant_status_changed (только при реальном изменении — повторный
/// suspend идемпотентен). Impersonation выпускает tenant-сессию целевого пользователя
/// (AuthService, механизм обычного входа; пароль не меняется) и возвращает {sessionToken, expiresAt,
/// tenantId, login} — токен используется как значение куки deal_session; аудит impersonation_started
/// (DetailJson: targetLogin, tenantId), завершение — logout'ом пользователя (impersonation_stopped в
/// AuthEndpoints). Зафиксированные решения Task 7: suspend-гейт отвечает 403 (не 401; см. AuthEndpoints),
/// impersonation suspended-тенанта разрешён (аудируется; ИИ заморожен гейтом Task 9), PATCH {status} плана
/// заменён на явные POST /suspend|/unsuspend, budget? при create не принимается до Task 8/10 — отклонения
/// для api-map/техдок Task 16 зафиксированы в task-7-report.md. Тексты ошибок — фиксированные строки
/// HTTP-слоя (паттерн OperatorInvitesEndpoints).
/// </remarks>
public static class OperatorTenantsEndpoints
{
// Текст 400: имя тенанта пустое/пробельное (create).
private const string TenantNameRequiredDetail = "Имя тенанта обязательно";
// Текст 400: email пустой/некорректного формата (create с владельцем).
private const string InvalidEmailDetail = "Некорректный email";
// Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, create с владельцем).
private const string EmailTakenDetail = "Этот email уже зарегистрирован";
// Текст 404: тенант с таким id не найден.
private const string TenantNotFoundDetail = "Тенант не найден";
// Текст 404: пользователь с таким login не найден в тенанте.
private const string UserNotFoundInTenantDetail = "Пользователь не найден в тенанте";
// Текст 400: в тенанте нет пользователей, а login не указан (impersonation без выбора).
private const string TenantHasNoUsersDetail = "В тенанте нет пользователей для входа";
// Префикс группы операторских ручек тенантов (Ruling 11).
private const string TenantsGroupPrefix = "/api/operator/tenants";
// Относительный путь деталей тенанта.
private const string TenantByIdPath = "/{id:guid}";
// Относительный путь приостановки тенанта.
private const string SuspendPath = "/{id:guid}/suspend";
// Относительный путь возобновления тенанта.
private const string UnsuspendPath = "/{id:guid}/unsuspend";
// Относительный путь impersonation пользователя тенанта.
private const string ImpersonatePath = "/{id:guid}/impersonate";
// OpenAPI-тег группы.
private const string TenantsOpenApiTag = "operator-tenants";
/// <summary>
/// Регистрирует группу /api/operator/tenants: список, create, детали, suspend/unsuspend, impersonate.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapOperatorTenantsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(TenantsGroupPrefix).WithTags(TenantsOpenApiTag);
group.MapGet("", ListAsync);
group.MapPost("", CreateAsync);
group.MapGet(TenantByIdPath, GetByIdAsync);
group.MapPost(SuspendPath, SuspendAsync);
group.MapPost(UnsuspendPath, UnsuspendAsync);
group.MapPost(ImpersonatePath, ImpersonateAsync);
return app;
}
// GET /api/operator/tenants: список тенантов со счётчиками пользователей (план Task 7).
private static async Task<IResult> ListAsync(
HttpContext context,
TenantAdminService tenantAdminService,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
IReadOnlyList<TenantListItemDto> items = await tenantAdminService.ListAsync(ct);
return Results.Ok(new { items });
}
// POST /api/operator/tenants: создание тенанта (Status active + провижининг схемы); аудит tenant_created.
// Решение Task 7: PATCH {status} заменён на явные POST /suspend и /unsuspend — create принимает только
// {name, email?}; budget?/лимиты — зона Task 8/10 (прецедент: join-строка лимитов отложена в Task 6).
private static async Task<IResult> CreateAsync(
OperatorTenantCreateRequest body,
HttpContext context,
TenantAdminService tenantAdminService,
AuditService auditService,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
TenantCreateResultDto result = await tenantAdminService.CreateAsync(body.Name, body.Email, ct);
if (!result.Ok || result.Tenant is null)
{
return result.Error switch
{
TenantCreateResultDto.ErrorInvalidEmail => EndpointResults.BadRequest(InvalidEmailDetail),
TenantCreateResultDto.ErrorEmailTaken => EndpointResults.BadRequest(EmailTakenDetail),
_ => EndpointResults.BadRequest(TenantNameRequiredDetail),
};
}
// Одноразовый пароль владельца в аудит/логи не пишется (правило секретов); raw — только в ответе ниже.
TenantRecordDto createdTenant = result.Tenant;
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.TenantCreated,
AuditActorTypes.Operator,
ActorId: operatorIdentity.OperatorId,
TenantId: createdTenant.Id,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { tenantId = createdTenant.Id, name = createdTenant.Name, email = result.OwnerLogin })), ct);
if (result.OwnerLogin is not null)
{
return Results.Ok(new
{
createdTenant.Id,
createdTenant.Name,
createdTenant.Status,
createdTenant.CreatedAt,
ownerEmail = result.OwnerLogin,
initialPassword = result.InitialPassword,
});
}
return Results.Ok(new { createdTenant.Id, createdTenant.Name, createdTenant.Status, createdTenant.CreatedAt });
}
// GET /api/operator/tenants/{id}: детали тенанта и его пользователи.
private static async Task<IResult> GetByIdAsync(
Guid id,
HttpContext context,
TenantAdminService tenantAdminService,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
TenantDetailDto? tenant = await tenantAdminService.GetAsync(id, ct);
if (tenant is null)
{
return EndpointResults.NotFound(TenantNotFoundDetail);
}
return Results.Ok(tenant);
}
// POST /api/operator/tenants/{id}/suspend: приостановка тенанта; аудит tenant_status_changed.
private static Task<IResult> SuspendAsync(
Guid id,
HttpContext context,
TenantAdminService tenantAdminService,
AuditService auditService,
CancellationToken ct) =>
ApplyStatusAsync(id, TenantStatuses.Suspended, context, tenantAdminService, auditService, ct);
// POST /api/operator/tenants/{id}/unsuspend: возобновление тенанта; аудит tenant_status_changed.
private static Task<IResult> UnsuspendAsync(
Guid id,
HttpContext context,
TenantAdminService tenantAdminService,
AuditService auditService,
CancellationToken ct) =>
ApplyStatusAsync(id, TenantStatuses.Active, context, tenantAdminService, auditService, ct);
// Общая логика suspend/unsuspend: проверка оператора, смена статуса, аудит при реальном изменении.
// id: Идентификатор тенанта.
// status: Новый статус — константа TenantStatuses.
// context: Контекст запроса (операторская сессия, IP).
// tenantAdminService: Сервис реестра тенантов.
// auditService: Сервис аудита (запись tenant_status_changed при изменении).
// ct: Токен отмены.
// Возвращает: 200 {ok,status} или 401/404 {detail}.
private static async Task<IResult> ApplyStatusAsync(
Guid id,
string status,
HttpContext context,
TenantAdminService tenantAdminService,
AuditService auditService,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
TenantStatusChangeResultDto result = await tenantAdminService.ChangeStatusAsync(id, status, ct);
if (!result.Ok || result.Tenant is null)
{
return EndpointResults.NotFound(TenantNotFoundDetail);
}
if (result.Changed)
{
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.TenantStatusChanged,
AuditActorTypes.Operator,
ActorId: operatorIdentity.OperatorId,
TenantId: result.Tenant.Id,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { tenantId = result.Tenant.Id, status = result.Tenant.Status })), ct);
}
return Results.Ok(new { ok = true, status = result.Tenant.Status });
}
// POST /api/operator/tenants/{id}/impersonate: tenant-сессия пользователя тенанта; аудит impersonation_started.
private static async Task<IResult> ImpersonateAsync(
Guid id,
OperatorTenantImpersonateRequest? body,
HttpContext context,
AuthService authService,
AuditService auditService,
IOptions<CookieOptions> cookieOptions,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
ImpersonationResultDto result = await authService.ImpersonateAsync(
id, body?.Login, operatorIdentity.OperatorId, ct);
if (!result.Ok || result.SessionToken is null || result.Login is null || result.TenantId is null)
{
return result.Error switch
{
ImpersonationResultDto.ErrorTenantNotFound => EndpointResults.NotFound(TenantNotFoundDetail),
ImpersonationResultDto.ErrorUserNotFound => EndpointResults.NotFound(UserNotFoundInTenantDetail),
_ => EndpointResults.BadRequest(TenantHasNoUsersDetail),
};
}
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.ImpersonationStarted,
AuditActorTypes.Operator,
ActorId: operatorIdentity.OperatorId,
TenantId: result.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { targetLogin = result.Login, tenantId = result.TenantId })), ct);
// Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
// Завершение — POST /api/auth/logout (пишет impersonation_stopped).
SessionCookieWriter.Append(context, cookieOptions.Value, result.SessionToken);
return Results.Ok(new
{
sessionToken = result.SessionToken,
expiresAt = result.ExpiresAt,
tenantId = result.TenantId,
login = result.Login,
});
}
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
}
@@ -0,0 +1,174 @@
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
using Deal.Modules.Pipeline.Application;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты вкладки «Обработка»: GET /api/pipeline/stats|queue|rejected, POST /api/pipeline/rejected/clear,
/// DELETE /api/pipeline/rejected/{rejId}, POST /api/pipeline/rejected/{rejId}/return (план Task 9 L437460,
/// Rulings 6/10; прототип processing_routes.py L1774).
/// </summary>
/// <remarks>
/// Контракт 1:1 с прототипом и api-map §3.6 L178186, §4.5: /stats → {queue:{new,ai,total}, rejected};
/// /queue?limit= → {items, counts:{new,ai,total}, rejected} (limit ≤500, дефолт 100, фронт шлёт 120);
/// /rejected?q=&amp;offset=&amp;limit= → {items, total, offset, limit} (q — FTS LIKE-поиск, Ruling 6);
/// /rejected/clear → {ok, cleared}; DELETE /rejected/{rejId} → {ok:true} всегда (delete_one L196198, 404 не
/// шлём — Ruling 10); /rejected/{rejId}/return {reason=""} → {id, returned:true, returnedAt} | 400 (строки
/// Ruling 10) | 404 «Запись не найдена» (текст 404 — слой эндпоинтов, паттерн CardsService → LeadsEndpoints).
/// Все эндпоинты требуют сессию: 401 {detail} без куки (Ruling 10); сервисы модуля резолвятся из
/// RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст запроса, паттерн SettingsEndpoints).
/// Статические сегменты (/stats, /queue, /rejected/clear) до параметризованного /rejected/{rejId} — порядок
/// как в прототипе (api-map L19), хотя литералы имеют приоритет в ASP.NET Core.
/// </remarks>
public static class PipelineEndpoints
{
// Префикс группы (роутер processing, prefix="/api/pipeline" — processing_routes.py L10).
private const string PipelineGroupPrefix = "/api/pipeline";
// OpenAPI-тег группы (в прототипе роутер processing — processing_routes.py L10).
private const string OpenApiTag = "processing";
// Путь сводки вкладки «Обработка» (GET).
private const string StatsPath = "/stats";
// Путь сырых сообщений очереди (GET).
private const string QueuePath = "/queue";
// Путь страницы отсева (GET).
private const string RejectedPath = "/rejected";
// Путь полной очистки отсева (POST).
private const string RejectedClearPath = "/rejected/clear";
// Путь удаления одной записи отсева (DELETE).
private const string RejectedIdPath = "/rejected/{rejId}";
// Путь возврата записи отсева в обработку (POST).
private const string RejectedReturnPath = "/rejected/{rejId}/return";
// 404 return: записи отсева нет (processing_routes.py L71: KeyError → 404, Ruling 10).
private const string RejectedNotFoundDetail = "Запись не найдена";
// Размер страницы по умолчанию списков очереди/отсева (processing.DEFAULT_LIMIT L48; фронт шлёт 120/80).
private const int DefaultPageSize = 100;
/// <summary>
/// Регистрирует группу /api/pipeline: stats/queue/rejected/clear/{rejId}/return.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapPipelineEndpoints(this IEndpointRouteBuilder app)
{
var pipeline = app.MapGroup(PipelineGroupPrefix).WithTags(OpenApiTag);
// Статические сегменты до /rejected/{rejId} (Ruling 10, api-map L19; порядок 1:1 с прототипом).
pipeline.MapGet(StatsPath, StatsAsync);
pipeline.MapGet(QueuePath, QueueAsync);
pipeline.MapGet(RejectedPath, RejectedAsync);
pipeline.MapPost(RejectedClearPath, ClearAsync);
pipeline.MapDelete(RejectedIdPath, DeleteAsync);
pipeline.MapPost(RejectedReturnPath, ReturnAsync);
return app;
}
// GET /api/pipeline/stats: сводка вкладки {queue:{new,ai,total}, rejected} (processing_routes.py L1720, stats L315320).
private static async Task<IResult> StatsAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
return Results.Ok(await processing.StatsAsync(ct));
}
// GET /api/pipeline/queue?limit=: сырые сообщения очереди + счётчики + число отсева (processing_routes.py L2331).
// Ответ {items, counts:{new,ai,total}, rejected} 1:1 с list_queue L218241 + queue_counts L207215 +
// rejected_count L201202. limit — дефолт 100, clamp 1..500 делает сервис (ListQueueAsync).
private static async Task<IResult> QueueAsync(int? limit, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
IReadOnlyList<QueueItemDto> items = await processing.ListQueueAsync(limit ?? DefaultPageSize, ct);
QueueCountsDto counts = await processing.QueueCountsAsync(ct);
int rejected = await processing.RejectedCountAsync(ct);
return Results.Ok(new { items, counts, rejected });
}
// GET /api/pipeline/rejected?q=&amp;offset=&amp;limit=: страница отсева (processing_routes.py L3442, list_rejected L246312).
// q — поиск по тексту/причине/фразе/имени канала (FTS LIKE, Ruling 6), пустой q — весь отсев свежими
// первыми; offset ≥ 0, limit 1..500 (clamp в сервисе), значения эхом в ответе {items,total,offset,limit}.
private static async Task<IResult> RejectedAsync(string? q, int? offset, int? limit, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
RejectedPageDto page = await processing.ListRejectedAsync(q ?? string.Empty, offset ?? 0, limit ?? DefaultPageSize, ct);
return Results.Ok(page);
}
// POST /api/pipeline/rejected/clear: полная безвозвратная очистка отсева (processing_routes.py L4549, clear_all L120125).
private static async Task<IResult> ClearAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
int cleared = await processing.ClearAsync(ct);
return Results.Ok(new { ok = true, cleared });
}
// DELETE /api/pipeline/rejected/{rejId}: удалить запись отсева; ответ {ok:true} всегда (delete_one L196198, Ruling 10).
// Прототип не проверяет наличие записи — 404 не шлём (план Task 9 L444; Ruling 10 «always ok»).
private static async Task<IResult> DeleteAsync(string rejId, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
await processing.DeleteAsync(rejId, ct);
return Results.Ok(new { ok = true });
}
// POST /api/pipeline/rejected/{rejId}/return {reason=""}: вернуть отсеянное в обработку (return_to_queue L128193).
// Успех — {id, returned:true, returnedAt} (запись помечается returned, НЕ удаляется — аудит Ruling 10);
// причины 400 (уже возвращено/повтор-dup/нет текста) — константы PipelineProcessingService (строки 1:1 с
// прототипом); записи нет — 404 «Запись не найдена» (текст 404 — слой эндпоинтов).
private static async Task<IResult> ReturnAsync(string rejId, ReturnReasonRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
RejectReturnResultDto? result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct);
if (result is null)
{
return EndpointResults.NotFound(RejectedNotFoundDetail);
}
return result.Error is not null
? EndpointResults.BadRequest(result.Error)
: Results.Ok(new { id = result.Id, returned = result.Returned, returnedAt = result.ReturnedAtMs });
}
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
}
@@ -0,0 +1,89 @@
using Deal.Api.Http;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// HTTP-эндпоинты курсов валют: GET /api/rates, POST /api/rates/refresh (Ruling 8, api-map §3.4 L149150).
/// </summary>
/// <remarks>
/// «Только для Settings-экрана» (Ruling 8): фронт читает курсы на boot (store.js L571581) и обновляет
/// по кнопке (refreshRates L18431848). GET — текущий кэш (ratesCache) или дефолт-мок; при протухании/
/// смене источника/отсутствии кэша (Ruling 6) фоново запускает RefreshAsync через
/// <see cref="RatesRefreshScheduler"/> и отвечает текущим кэшем (план Task 8 L317318). POST — синхронный
/// refresh 1:1 с прототипом: <c>{ok: bool, rates: {base, rates, source, updatedAt}}</c> (ok=false при сбое
/// ЦБ, кэш не тронут). Оба требуют сессию: 401 {detail} (Ruling 10). Резолв scoped-зависимостей — через
/// RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints/AiCheckEndpoint: ISettingsStore требует
/// tenant-контекст запроса).
/// </remarks>
public static class RatesEndpoints
{
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
private const string ApiGroupPrefix = "/api";
// Путь текущих курсов (GET).
private const string RatesPath = "/rates";
// Путь принудительного обновления (POST).
private const string RatesRefreshPath = "/rates/refresh";
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
private const string OpenApiTag = "settings";
/// <summary>
/// Регистрирует GET /api/rates и POST /api/rates/refresh.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapRatesEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
group.MapGet(RatesPath, GetRatesAsync);
group.MapPost(RatesRefreshPath, RefreshRatesAsync);
return app;
}
// GET /api/rates: текущий кэш курсов тенанта (+ ленивый фоновый refresh при необходимости).
private static async Task<IResult> GetRatesAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
// Резолв после 401-гейта: RatesService — scoped на TenantDbContext (tenant-контекст запроса).
RatesService ratesService = context.RequestServices.GetRequiredService<RatesService>();
RatesDto current = await ratesService.GetAsync(ct);
// Ленивое обновление (Ruling 6, план Task 8): протухший кэш / смена источника / нет кэша —
// фоновый RefreshAsync в отдельном scope; ответ — текущий кэш.
if (await ratesService.ShouldFetchAsync(ct))
{
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
}
return Results.Ok(current);
}
// POST /api/rates/refresh: принудительное обновление; ответ {ok, rates} (1:1 settings_routes.py L229232).
private static async Task<IResult> RefreshRatesAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
RatesService ratesService = context.RequestServices.GetRequiredService<RatesService>();
// Синхронно: refresh по текущей настройке rateSource; при сбое ЦБ ok=false и кэш не тронут.
bool ok = await ratesService.RefreshAsync(ct);
RatesDto current = await ratesService.GetAsync(ct);
return Results.Ok(new { ok, rates = current });
}
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
}
@@ -0,0 +1,13 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/{cardId}/links — добавление ссылки карточке (add_link L133143).
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: name (пустой по умолчанию) и url. url Trim'ится; пустой url → 400 «Пустая
/// ссылка» (валидация CardsService.AddLinkAsync); url без схемы http://https:// получает префикс https://.
/// Пустое name → ссылка называется url. Ответ — карточка после мутации.
/// </remarks>
/// <param name="Name">Название ссылки; пустое → name = url.</param>
/// <param name="Url">URL ссылки (без схемы — добавится https://).</param>
public sealed record CardLinkRequest(string? Name = null, string? Url = null);
@@ -0,0 +1,10 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/clear-col — полная очистка служебной колонки (dashboard_routes.py ClearColBody L224225, api-map §3.2 L93).
/// </summary>
/// <remarks>
/// Wire-имя — camelCase: col — "trash" | "archive" (другие колонки/отсутствие значения → 400
/// «Очищать можно только корзину или архив», валидация CardsService.ClearColAsync L237247).
/// </remarks>
public sealed record ClearColBody(string? Col);
@@ -0,0 +1,11 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки (этап 9, T4).
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: collapsed (bool) / width ("sm"|"md"|"lg"). Поле со значением null (либо
/// отсутствующее) текущее значение не меняет (прототип model_dump(exclude_none=True) + merge в текущее
/// состояние колонки, L146–149). Пустой патч {} сохраняет текущее состояние (для новой колонки — {}).
/// </remarks>
public sealed record ColStateBody(bool? Collapsed, string? Width);
@@ -0,0 +1,10 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/{cardId}/comments — добавление комментария (dashboard_routes.py CommentBody L6061).
/// </summary>
/// <remarks>
/// Wire-имя — camelCase: text. Пустой/пробельный текст либо явный null → 400 «Пустой комментарий»
/// (валидация CardsService.AddCommentAsync, 1:1 с dashboard_routes L240241).
/// </remarks>
public sealed record CommentBody(string? Text);
@@ -0,0 +1,30 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/containers — создание контейнера (этап 9, T4).
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: name/description/color/space/kind/suggested/note/rules. Name — обязательное:
/// отсутствие либо явный null → 400 «Укажите название колонки»; пустая строка допустима — сервис
/// подставит «Новая колонка». Description/Note имеют дефолт "". Отсутствующие группы правил
/// трактуются как пустые (null-устойчивость).
/// </remarks>
/// <param name="Name">Имя контейнера (обязательно).</param>
/// <param name="Description">Описание (опционально).</param>
/// <param name="Color">Цвет (опционально; null — палитра).</param>
/// <param name="Space">Пространство (dashboard/selected; по умолчанию dashboard).</param>
/// <param name="Kind">Вид (board/stage/service/terminal; по умолчанию board).</param>
/// <param name="Suggested">Признак ИИ-предложения.</param>
/// <param name="Rules">Правила попадания.</param>
/// <param name="Note">Заметка/обоснование.</param>
public sealed record ContainerCreateRequest(
string? Name,
string? Description,
string? Color,
string? Space,
string? Kind,
bool? Suggested,
ContainerRulesDto? Rules,
string? Note);
@@ -0,0 +1,29 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело PATCH /api/containers/{id} — частичное обновление контейнера (этап 9, T4).
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: name/description/color/collapsed/suggested/note/rules/policy. Поле со
/// значением null означает «не менять»; исключение — ЯВНЫЙ null у name → 400 «Укажите название колонки».
/// JSON-объекты rules/policy заменяются целиком.
/// </remarks>
/// <param name="Name">Новое имя (null — не менять).</param>
/// <param name="Description">Новое описание (null — не менять).</param>
/// <param name="Color">Новый цвет (null — не менять).</param>
/// <param name="Collapsed">Новая свёрнутость (null — не менять).</param>
/// <param name="Suggested">Признак ИИ-предложения (null — не менять).</param>
/// <param name="Note">Новая заметка (null — не менять).</param>
/// <param name="Rules">Новые правила (null — не менять).</param>
/// <param name="Policy">Новая политика (null — не менять).</param>
public sealed record ContainerPatchRequest(
string? Name,
string? Description,
string? Color,
bool? Collapsed,
bool? Suggested,
string? Note,
ContainerRulesDto? Rules,
ContainerPolicyDto? Policy);
@@ -0,0 +1,29 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards — ручное («локальное») создание карточки (этап 9, T6).
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: title/summary/stack/budget/contact/tzText/containerId (алиас stage).
/// containerId — id контейнера (стадии/доски); неизвестный/отсутствующий не отвергается: карточка
/// создаётся в planned. budget — объект {from,to,cur} либо null.
/// </remarks>
/// <param name="Title">Заголовок карточки (Trim() в сервисе; пустой допустим).</param>
/// <param name="Summary">Краткое содержание карточки.</param>
/// <param name="Stack">Стек/направления (null — пустой стек).</param>
/// <param name="Budget">Бюджет (from/to/cur); null — бюджета нет.</param>
/// <param name="Contact">Контактная строка карточки.</param>
/// <param name="TzText">Текст технического задания.</param>
/// <param name="ContainerId">Желаемый контейнер; null/неизвестный → planned.</param>
/// <param name="Stage">Алиас containerId (совместимость со старым фронтом).</param>
public sealed record CreateCardRequest(
string Title = "",
string Summary = "",
IReadOnlyList<string>? Stack = null,
CardBudgetDto? Budget = null,
string Contact = "",
string TzText = "",
string? ContainerId = null,
string? Stage = null);
@@ -0,0 +1,58 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/discovery/tasks (TaskCreate discovery_routes.py L5060; api-map §3.8 L204).
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: name/description/keywords/minSubscribers/lang/threshold/sampleSize/planJoins/autoJoin.
/// Значения-дефолты pydantic повторяет сервис DiscoveryTasksService: description/keywords — пустые, lang — «ru»,
/// minSubscribers — 0, planJoins — 1, autoJoin — false; threshold/sampleSize — из настроек (null → дефолт).
/// name — единственное поле без дефолта: пустое/пробельное значение → 400 «Укажите название задачи».
/// </remarks>
public sealed record DiscoveryTaskCreateBody
{
/// <summary>
/// Название задачи (обязательное; Trim, пустое → 400).
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Описание ниши/цели (источник для generate-keywords); null → пустая строка.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Ключевые слова поиска; null/пусто — список пуст (start до добавления ключей → 400).
/// </summary>
public IReadOnlyList<string>? Keywords { get; init; }
/// <summary>
/// Минимальное число участников источника; null → 0.
/// </summary>
public int? MinSubscribers { get; init; }
/// <summary>
/// Язык источников: «ru»|«any»; null/иное → «ru» (нормализует сервис).
/// </summary>
public string? Lang { get; init; }
/// <summary>
/// Порог подходящих сообщений оценки, % (кламп 1..100); null → discEvalThreshold.
/// </summary>
public int? Threshold { get; init; }
/// <summary>
/// Размер выборки сообщений оценки (кламп ≥1); null → discEvalSample.
/// </summary>
public int? SampleSize { get; init; }
/// <summary>
/// План авто-вступлений (1..discJoinLimit + бюджет); null → 1.
/// </summary>
public int? PlanJoins { get; init; }
/// <summary>
/// Авто-вступления воркером; null → false.
/// </summary>
public bool? AutoJoin { get; init; }
}
@@ -0,0 +1,57 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело PATCH /api/discovery/tasks/{task_id} (TaskPatch discovery_routes.py L6271; api-map §3.8 L205).
/// </summary>
/// <remarks>
/// Wire-имена — camelCase; все поля optional: null/отсутствующее поле не меняется (в DiscoveryTaskPatch
/// пробрасываются только не-null значения, как python model_dump(exclude_none=True)). keywords — полная замена
/// списка (пустой список очищает ключи); увеличение planJoins проверяется план-бюджетом.
/// </remarks>
public sealed record DiscoveryTaskPatchBody
{
/// <summary>
/// Новое название (после Trim; пустое допустимо на patch — 1:1 прототип).
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Новое описание (пустая строка очищает).
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Новые ключевые слова (полная замена; null — не менять).
/// </summary>
public IReadOnlyList<string>? Keywords { get; init; }
/// <summary>
/// Новый минимум участников (кламп ≥0).
/// </summary>
public int? MinSubscribers { get; init; }
/// <summary>
/// Новый язык: «ru»|«any» (иное → «ru»).
/// </summary>
public string? Lang { get; init; }
/// <summary>
/// Новый порог оценки, % (кламп 1..100).
/// </summary>
public int? Threshold { get; init; }
/// <summary>
/// Новый размер выборки (кламп ≥1).
/// </summary>
public int? SampleSize { get; init; }
/// <summary>
/// Новый план авто-вступлений (рост — с проверкой бюджета).
/// </summary>
public int? PlanJoins { get; init; }
/// <summary>
/// Новый флаг авто-вступлений (false — выключить).
/// </summary>
public bool? AutoJoin { get; init; }
}
@@ -0,0 +1,12 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки (dashboard_routes.py MarkColBody L183184, api-map §3.2 L88).
/// </summary>
/// <remarks>
/// Wire-имя — camelCase: col — колонка (inbox/archive/trash/доска). Отсутствие/null col → 400
/// «Неизвестная колонка»: иначе пустой col попал бы в CardsService.MarkSeenAsync и снял «новое» со ВСЕХ
/// карточек (truthiness-семантика прототипа: пустая строка = параметр не задан, mark_seen L250256) —
/// эндпоинт защищает от такого вызова (прототип: pydantic required 422).
/// </remarks>
public sealed record MarkColBody(string? Col);
@@ -0,0 +1,11 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/{lead_id}/move — перенос карточки (dashboard_routes.py MoveBody L5657, api-map §3.2 L89).
/// </summary>
/// <remarks>
/// Wire-имя — camelCase: to — колонка назначения: "inbox" либо id доски (<c>b_...</c>). Цель валидирует
/// CardsService (400 «Переносить можно только на доски или в «Неразобранное»»); отсутствующий/null to
/// трактуются той же валидацией (прототип — pydantic required 422).
/// </remarks>
public sealed record MoveBody(string? To);
@@ -0,0 +1,11 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело PUT /api/operator/settings/telegram-keys: глобальные ключи приложения Telegram,
/// задаваемые оператором (ТЗ §4.1/§8.1). Поддерживается частичное обновление: непереданное поле
/// (<c>null</c>) сохраняет текущее значение, явное значение валидируется. Если ключей ещё нет,
/// оба поля обязательны.
/// </summary>
/// <param name="ApiId">api_id приложения Telegram (5..9 цифр); null — не менялось.</param>
/// <param name="ApiHash">api_hash приложения Telegram (непустой секрет; хранится зашифрованным); null — не менялось.</param>
public sealed record OperatorTelegramKeysRequest(string? ApiId, string? ApiHash);
@@ -0,0 +1,12 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства (этап 9, T4).
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: space (пространство dashboard/selected; отсутствие — dashboard) и order
/// (список id контейнеров в новом порядке). Отсутствие/явный null у order — 400 «Не указан порядок колонок».
/// </remarks>
/// <param name="Space">Пространство (dashboard/selected); null — dashboard.</param>
/// <param name="Order">Id контейнеров в новом порядке.</param>
public sealed record OrderBody(string? Space, IReadOnlyList<string>? Order);
@@ -0,0 +1,11 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного» (dashboard_routes.py ReclassifyBody L6465, api-map §3.2 L95).
/// </summary>
/// <remarks>
/// Wire-имя — camelCase: ids (опциональный список id карточек). Тело опционально (фронт вызывает без
/// тела — reclassifyInbox, store.js L11151133; параметр эндпоинта nullable). В этапе 3 — заглушка
/// Ruling 11: тело не используется, ответ всегда {started:false, busy:false, attempted:0, reason}.
/// </remarks>
public sealed record ReclassifyBody(IReadOnlyList<string>? Ids);
@@ -0,0 +1,17 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке
/// (projects_routes.py ReminderBody L5253, api-map §3.5 L172; Ruling 3).
/// </summary>
/// <remarks>
/// Wire-имя — camelCase: at — время напоминания в epoch-мс (рассчитывает фронт HoldReminderDialog:
/// «через N дней (1–30)» или «дата+время» локального времени; store.js setHoldReminder L20602086).
/// Стадия карточки/будущность at сервисом НЕ проверяются (1:1 прототип: фронт шлёт только для hold;
/// прошлое at допустимо — приёмка Tasks 11/13 «выстреливает» его ручным тиком). Ответ — полная карточка
/// с напоминанием {at}; 400 «Напоминания об отложенных выключены в настройках» при выключенном
/// remindersEnabled; 404 «Карточка не найдена». Отсутствующий/JSON-null at (клиентский баг; pydantic на
/// такое — 422) эндпоинт отвергает 400 — у напоминания без времени нет осмысленной семантики.
/// </remarks>
/// <param name="At">Время напоминания, epoch-ms.</param>
public sealed record ReminderSetRequest(long? At);
@@ -0,0 +1,11 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку (processing_routes.py ReturnBody L1315, api-map §3.6 L185).
/// </summary>
/// <remarks>
/// Wire-имя — camelCase: reason (опциональна, дефолт "" — как pydantic reason: str = ""; фронт шлёт
/// {reason} всегда). Пустая причина допустима: сервис тримит и кладёт на запись для аудита
/// (return_to_queue L158, Ruling 10).
/// </remarks>
public sealed record ReturnReasonRequest(string? Reason);
@@ -0,0 +1,13 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/take — «взять в работу» карточки с дашборда (этап 9, T6).
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: cardId — id карточки; leadId — алиас (совместимость со старым фронтом).
/// Карточка не клонируется: она переносится в контейнер planned. Отсутствующий/несуществующий id →
/// 404 «Карточка не найдена».
/// </remarks>
/// <param name="CardId">Id карточки, берущейся в работу.</param>
/// <param name="LeadId">Алиас cardId.</param>
public sealed record TakeCardRequest(string? CardId = null, string? LeadId = null);
@@ -0,0 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/tg/dialogs/{dialog_id}/monitor и /monitor-all (tg_routes.py MonitorBody L5456).
/// </summary>
/// <param name="Enabled">True — мониторить (сообщения → PushMessage в ядро), false — выключить.</param>
public sealed record TgMonitorBody(bool Enabled);
@@ -0,0 +1,8 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/tg/dialogs/preview — последние сообщения диалога (tg_routes.py PreviewBody L5860).
/// </summary>
/// <param name="DialogId">Id диалога (подписанный).</param>
/// <param name="Limit">Сколько последних сообщений; дефолт 24, кламп 1..50 (python L153).</param>
public sealed record TgPreviewBody(string DialogId, int? Limit);
@@ -0,0 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/tg/send-code — SMS-код входа (tg_routes.py CodeBody L4648).
/// </summary>
/// <param name="Code">Код из SMS/Telegram-сообщения (trim перед отправкой, python L89).</param>
public sealed record TgSendCodeRequest(string Code);
@@ -0,0 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/tg/send-password — облачный пароль 2FA (tg_routes.py PasswordBody L5052).
/// </summary>
/// <param name="Password">Пароль облачной защиты (как ввёл пользователь, без trim — python L100).</param>
public sealed record TgSendPasswordRequest(string Password);
@@ -0,0 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/tg/start-phone — вход по номеру телефона (tg_routes.py PhoneBody L4244).
/// </summary>
/// <param name="Phone">Номер в международном формате (как ввёл пользователь; обрезается обработчиком, python L71).</param>
public sealed record TgStartPhoneRequest(string Phone);
@@ -0,0 +1,132 @@
using System.Text.Json;
using Deal.Api;
using Deal.Api.Http;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application;
namespace Deal.Api.Endpoints;
/// <summary>
/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146147, §4.6).
/// </summary>
/// <remarks>
/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3);
/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения
/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию:
/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH
/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400).
/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое
/// обновление кэша курсов (<see cref="RatesRefreshScheduler"/>, Ruling 6); пересчёт карточек при смене
/// targetCurrency/conversionOn выполняет сам SettingsService через порт <see cref="IRatesChangedListener"/>
/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12).
/// <para>
/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не
/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость
/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса
/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401,
/// а не 500 при резолве.
/// </para>
/// </remarks>
public static class SettingsEndpoints
{
private const string ApiGroupPrefix = "/api";
private const string SettingsPath = "/settings";
private const string SettingsOpenApiTag = "settings";
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
/// <summary>
/// Регистрирует GET/PATCH /api/settings.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(ApiGroupPrefix).WithTags(SettingsOpenApiTag);
group.MapGet(SettingsPath, GetSettingsAsync);
group.MapPatch(SettingsPath, PatchSettingsAsync);
return app;
}
// GET /api/settings: публичный снимок настроек текущего тенанта.
private static async Task<IResult> GetSettingsAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
return Results.Ok(await settingsService.GetPublicAsync(ct));
}
// PATCH /api/settings: частичное обновление настроек; ответ — полный снимок после применения.
private static async Task<IResult> PatchSettingsAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
// Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт).
Dictionary<string, JsonElement>? body;
try
{
body = await JsonSerializer.DeserializeAsync<Dictionary<string, JsonElement>>(
context.Request.Body,
options: null,
cancellationToken: ct);
}
catch (JsonException)
{
// Не-JSON или не-объект целиком — ошибка запроса: 400 + detail
// (в прототипе FastAPI на такое тело — 422).
return EndpointResults.BadRequest(InvalidBodyDetail);
}
if (body is null)
{
return EndpointResults.BadRequest(InvalidBodyDetail);
}
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
// Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся.
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
// Смена источника курсов в PATCH (settings_routes.py L188189) — фоновое обновление кэша
// курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource.
if (ShouldScheduleRatesRefresh(body))
{
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
}
return Results.Ok(result);
}
// Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188).
// body: Тело PATCH — публичные ключи §4.6.
// Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка).
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
{
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
{
return false;
}
// JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет.
return element.ValueKind switch
{
JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()),
JsonValueKind.Null => false,
_ => true,
};
}
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
}
@@ -0,0 +1,91 @@
using Deal.Api.Http;
using Deal.Infrastructure.Services;
namespace Deal.Api.Endpoints;
/// <summary>
/// Служебные storage-эндпоинты: POST /api/admin/tick и POST /api/admin/fts/rebuild (план Tasks 1011,
/// Rulings 6/8/11; прототип dashboard_routes.py L261264, L327337).
/// </summary>
/// <remarks>
/// Контракт 1:1 с прототипом и api-map §3.2 L103112: POST /admin/tick = тик правил хранения текущего
/// тенанта + очистка отсева пайплайна (3 суток) + проверка напоминаний «Отложено» (план Task 11, Ruling 3/8)
/// + один проход pump очереди входящих (этап 4, Ruling 8/9);
/// ответ {storage, reminders, pipeline: {…}, queue: N} (dashboard_routes.py L327337; storage.purgedRejected
/// объединяет очистку отсева — Ruling 9; reminders — «выстрелившие» напоминания {id,title,stage}, пусто —
/// сработавших нет). SSE-публикации (тосты статистики notify_tick_stats L496504, new_card по созданным
/// карточкам и reminder_due по «выстрелившим» напоминаниям) выполняет <see cref="AdminTickOrchestrator"/> из
/// Api-слоя — модули остаются чистыми (Ruling 5/8); без подписчиков публикация — no-op. Сбой проверки
/// напоминаний/pump не роняет тик: reminders/pipeline ответа пусты, очередь ждёт следующего тика/фонового
/// цикла (Task 11). POST /admin/fts/rebuild —
/// реальная идемпотентная пересборка FTS-индексов <see cref="FtsMaintenance"/> (CREATE INDEX IF NOT EXISTS +
/// ANALYZE, Ruling 6), ответ {ok:true, ready:true} (при сбое {ok:false, ready:false} — 1:1 с fts_rebuild L261264,
/// кнопка Settings «Пересобрать индекс» store.js L18831889). Оба эндпоинта требуют сессию: 401 {detail} без
/// куки (Ruling 10); сервисы резолвятся из RequestServices ПОСЛЕ проверки сессии (паттерн BoardsEndpoints).
/// </remarks>
public static class StorageEndpoints
{
// Префикс группы (роутер dashboard, prefix="/api"; admin-пути прототипа L261/L327).
private const string AdminGroupPrefix = "/api";
// Путь ручного тика правил хранения (dashboard_routes.py L327).
private const string TickPath = "/admin/tick";
// Путь пересборки поискового индекса (dashboard_routes.py L261; Ruling 6).
private const string FtsRebuildPath = "/admin/fts/rebuild";
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
private const string OpenApiTag = "dashboard";
/// <summary>
/// Регистрирует POST /api/admin/tick и POST /api/admin/fts/rebuild.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapStorageEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup(AdminGroupPrefix).WithTags(OpenApiTag);
admin.MapPost(TickPath, AdminTickAsync);
admin.MapPost(FtsRebuildPath, FtsRebuildAsync);
return app;
}
// POST /api/admin/tick: правила хранения + очистка отсева + напоминания + pump + SSE-тосты/new_card/reminder_due (admin_tick L327337).
// Весь состав тика — AdminTickOrchestrator (вынесен из эндпоинта для unit-тестов логики и
// переиспользования): тик StorageTickService (Kanban) → PurgeExpiredAsync (отсев 3 суток, merge в
// storage.purgedRejected) → тосты статистики (включая «Отсев очищен: N записей (3 дн.)») → CheckDueAsync
// (напоминания «Отложено»: reminders ответа + SSE reminder_due, план Task 11; сбой не роняет тик) →
// PumpOnceAsync (сбой не роняет тик) → SSE new_card по созданным карточкам → queue. Формы — 1:1 с прототипом.
private static async Task<IResult> AdminTickAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
AdminTickOrchestrator orchestrator = context.RequestServices.GetRequiredService<AdminTickOrchestrator>();
Guid tenantId = context.GetCurrentUser()!.TenantId;
return Results.Ok(await orchestrator.TickAsync(tenantId, ct));
}
// POST /api/admin/fts/rebuild: пересборка FTS-индексов тенанта; ответ {ok:true, ready:true} (Ruling 6).
// SearchTsv — генерируемые STORED-колонки Cards/RejectedItems: авто-актуальны, «пересборка» = создание
// отсутствующих GIN-индексов (CREATE INDEX IF NOT EXISTS) + ANALYZE таблиц (FtsMaintenance.RebuildAsync).
// Сбой обслуживания возвращает {ok:false, ready:false} (прототип fts_rebuild L261264: rebuild() → ok,
// is_ready() → ready) — кнопка Settings фронта показывает ошибку по ready (store.js L18831889).
private static async Task<IResult> FtsRebuildAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
FtsMaintenance fts = context.RequestServices.GetRequiredService<FtsMaintenance>();
bool ok = await fts.RebuildAsync(ct);
return Results.Ok(new { ok, ready = ok });
}
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
}
@@ -0,0 +1,535 @@
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
using Deal.Api.Telegram;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Telegram.Application.Models;
using Deal.Modules.Tenants.Application;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты /api/tg: статус, веб-авторизация (phone/QR/код/2FA/logout), диалоги и мониторинг (Ruling 8, api-map §3.3).
/// </summary>
/// <remarks>
/// Тела ответов 1:1 с прототипом <c>backend/app/routers/tg_routes.py</c>:
/// <c>status</c> — §4.9 (собирает <see cref="TgStatusService"/>); start-phone/send-code/send-password — <c>{phase}</c>;
/// start-qr — <c>{phase, qrUrl}</c>; logout — <c>{ok:true}</c>; dialogs — <c>{items:[диалог §4.8]}</c> (type — русская
/// форма на границе: канал/группа/чат, заметка Task 1); refresh — <c>{ok, count}</c> либо <c>{ok:false,
/// reason:"not-connected", count:0}</c> (мягкая ветка L119120); monitor/monitor-all/backfill-all/preview — как в
/// §3.3. Ошибки гейта (недоступный сервис/доменный отказ RPC) — 400 <c>{detail}</c> с канонической причиной
/// (Ruling 7/8). Фоновый первый разбор при включении мониторинга и «Перечитать» — <see cref="TelegramBackfillScheduler"/>
/// (python-_spawn L546/L566/L580). Все эндпоинты требуют сессию: 401 {detail} (Ruling 10). Сервисы резолвятся
/// из RequestServices ПОСЛЕ проверки сессии (scoped — TenantDbContext схемы тенанта, паттерн SettingsEndpoints).
/// </remarks>
public static class TelegramEndpoints
{
// Префикс группы /api/tg (python: router prefix, tg_routes.py L12).
private const string TgGroupPrefix = "/api/tg";
// OpenAPI-тег группы (в прототипе роутер tg — tg_routes.py).
private const string TgOpenApiTag = "telegram";
// Путь статуса аккаунта/фазы входа (GET).
private const string StatusPath = "/status";
// Путь входа по телефону (POST).
private const string StartPhonePath = "/start-phone";
// Путь начала QR-входа (POST).
private const string StartQrPath = "/start-qr";
// Путь отправки SMS-кода (POST).
private const string SendCodePath = "/send-code";
// Путь облачного пароля 2FA (POST).
private const string SendPasswordPath = "/send-password";
// Путь отключения аккаунта (POST).
private const string LogoutPath = "/logout";
// Путь списка диалогов из БД (GET).
private const string DialogsPath = "/dialogs";
// Путь синхронизации каталога из Telegram (POST).
private const string DialogsRefreshPath = "/dialogs/refresh";
// Путь мониторинга всех каналов (POST).
private const string DialogsMonitorAllPath = "/dialogs/monitor-all";
// Путь «Перечитать» всех включённых каналов (POST).
private const string DialogsBackfillAllPath = "/dialogs/backfill-all";
// Путь превью последних сообщений диалога (POST).
private const string DialogsPreviewPath = "/dialogs/preview";
// Путь включения/выключения мониторинга одного диалога (POST, параметр dialog_id).
private const string DialogMonitorPath = "/dialogs/{dialog_id}/monitor";
// Путь разбора одного диалога (POST, параметр dialog_id; сервер-only, api-map п.9).
private const string DialogBackfillPath = "/dialogs/{dialog_id}/backfill";
// Деталь 400: сохранённый api_id не является корректным числом (Security review: int.TryParse).
private const string InvalidApiIdDetail = "Некорректный api_id — проверьте глобальные ключи Telegram";
// Деталь 400 без ключей приложения: оператор не задал глобальные ключи Telegram (ТЗ §4.1/§8.1).
private const string NoKeysDetail = "Ключи Telegram не заданы оператором";
// Деталь недоступного telegram-service/неподключённого аккаунта (глобальная строка контракта).
private const string NotConnectedDetail = "Telegram не подключён";
// Reason мягкой ветки refresh: аккаунт не подключён (tg_routes.py L120).
private const string NotConnectedReason = "not-connected";
// Фаза успешной привязки аккаунта Telegram (telegram_linked — этап 10, T1).
private const string ReadyPhase = "ready";
// Дефолт limit превью (PreviewBody L60: limit = 24).
private const int PreviewDefaultLimit = 24;
// Нижняя граница limit превью (python L153: min(…, 1)).
private const int PreviewLimitMin = 1;
// Верхняя граница limit превью (python L153: max(…, 50); api-map /dialogs/preview).
private const int PreviewLimitMax = 50;
/// <summary>
/// Регистрирует группу /api/tg: status/start-phone/start-qr/send-code/send-password/logout/dialogs/refresh/
/// monitor-all/backfill-all/preview/{dialog_id}/monitor/{dialog_id}/backfill (qr-image — TelegramQrImageEndpoint).
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(TgGroupPrefix).WithTags(TgOpenApiTag);
group.MapGet(StatusPath, StatusAsync);
group.MapPost(StartPhonePath, StartPhoneAsync);
group.MapPost(StartQrPath, StartQrAsync);
group.MapPost(SendCodePath, SendCodeAsync);
group.MapPost(SendPasswordPath, SendPasswordAsync);
group.MapPost(LogoutPath, LogoutAsync);
group.MapGet(DialogsPath, DialogsAsync);
group.MapPost(DialogsRefreshPath, DialogsRefreshAsync);
group.MapPost(DialogsMonitorAllPath, MonitorAllAsync);
group.MapPost(DialogsBackfillAllPath, BackfillAllAsync);
group.MapPost(DialogsPreviewPath, PreviewAsync);
group.MapPost(DialogMonitorPath, SetMonitorAsync);
group.MapPost(DialogBackfillPath, BackfillDialogAsync);
return app;
}
// GET /api/tg/status: статус аккаунта/фазы входа (tg_routes.py L6365; форма §4.9).
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
TgStatusService statusService = context.RequestServices.GetRequiredService<TgStatusService>();
return Results.Ok(await statusService.GetAsync(ct));
}
// POST /api/tg/start-phone: запросить код по номеру (tg_routes.py L6874; python L134147).
private static async Task<IResult> StartPhoneAsync(TgStartPhoneRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
TgKeysSnapshot? keys = await ReadKeysOrNullAsync(context, ct);
if (keys is null)
{
return EndpointResults.BadRequest(NoKeysDetail);
}
try
{
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
if (!TryParseApiId(keys.ApiId, out int apiId))
{
return EndpointResults.BadRequest(InvalidApiIdDetail);
}
TelegramAuthResultDto result = await gateway.StartPhoneAsync(
(body.Phone ?? string.Empty).Trim(), apiId, keys.ApiHash, ct);
return Results.Ok(new { phase = result.Phase });
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return GatewayError(exception);
}
}
// POST /api/tg/start-qr: начать QR-вход (tg_routes.py L7783; python qr_start L286300).
private static async Task<IResult> StartQrAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
TgKeysSnapshot? keys = await ReadKeysOrNullAsync(context, ct);
if (keys is null)
{
return EndpointResults.BadRequest(NoKeysDetail);
}
try
{
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
if (!TryParseApiId(keys.ApiId, out int apiId))
{
return EndpointResults.BadRequest(InvalidApiIdDetail);
}
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
if (result.Phase == ReadyPhase)
{
// Аудит привязки Telegram (этап 10, T1): аккаунт уже авторизован — фаза ready.
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase = result.Phase }, ct);
}
return Results.Ok(new { phase = result.Phase, qrUrl = result.QrUrl ?? string.Empty });
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return GatewayError(exception);
}
}
// POST /api/tg/send-code: отправить SMS-код (tg_routes.py L8694; python submit_code L149166).
private static async Task<IResult> SendCodeAsync(TgSendCodeRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
try
{
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
if (phase == ReadyPhase)
{
// Аудит привязки Telegram (этап 10, T1): вход завершён без 2FA — фаза ready.
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
}
return Results.Ok(new { phase });
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return GatewayError(exception);
}
}
// POST /api/tg/send-password: облачный пароль 2FA (tg_routes.py L97103; python submit_password L168176).
private static async Task<IResult> SendPasswordAsync(TgSendPasswordRequest body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
try
{
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
if (phase == ReadyPhase)
{
// Аудит привязки Telegram (этап 10, T1): 2FA пройдена — фаза ready.
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
}
return Results.Ok(new { phase });
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return GatewayError(exception);
}
}
// POST /api/tg/logout: отключить аккаунт, удалить сессию (tg_routes.py L106109; python disconnect L189207).
private static async Task<IResult> LogoutAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
try
{
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
await gateway.LogoutAsync(ct);
return Results.Ok(new { ok = true });
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return GatewayError(exception);
}
}
// GET /api/tg/dialogs: список диалогов из БД (tg_routes.py L112114; list_dialogs L521534).
private static async Task<IResult> DialogsAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
IReadOnlyList<TelegramDialogDto> rows = await dialogs.ListAsync(ct);
// Форма §4.8 L349: {id, name, handle, type, hue, on, last:{text,time}}; type — русская форма на границе
// (EN-канон каталога channel/group/forum/chat → «канал»/«группа»/«чат», заметка Task 1/«кривое место» п.4).
var items = new List<object?>(rows.Count);
foreach (TelegramDialogDto dialog in rows)
{
items.Add(new
{
id = dialog.Id,
name = dialog.Name,
handle = dialog.Handle,
type = ToRussianDialogType(dialog.Type),
hue = dialog.Hue,
on = dialog.On,
last = dialog.Last is null
? null
: new { text = dialog.Last.Text, time = dialog.Last.TimeMs },
});
}
return Results.Ok(new { items });
}
// POST /api/tg/dialogs/refresh: синхронизировать каталог диалогов из Telegram (tg_routes.py L117122).
private static async Task<IResult> DialogsRefreshAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
// 1:1 tg_routes.py L119120: аккаунт не подключён (или сервис недоступен — Ruling 7) → мягкая ветка
// {ok:false, reason:"not-connected", count:0} HTTP 200 — refresh не ошибка запроса.
bool connected;
try
{
connected = (await gateway.StatusAsync(ct)).Connected;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
connected = false;
}
if (!connected)
{
return Results.Ok(new { ok = false, reason = NotConnectedReason, count = 0 });
}
try
{
IReadOnlyList<TelegramDialogEntryDto> entries = await gateway.RefreshDialogsAsync(ct);
int count = await dialogs.SyncFromTelegramAsync(entries, ct);
return Results.Ok(new { ok = true, count });
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Сбой чтения каталога (flood/временный) — та же мягкая ветка: каталог остаётся как в БД.
return Results.Ok(new { ok = false, reason = NotConnectedReason, count = 0 });
}
}
// POST /api/tg/dialogs/monitor-all: мониторинг всех каналов (tg_routes.py L125129; L548567).
private static async Task<IResult> MonitorAllAsync(TgMonitorBody body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
try
{
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
TelegramMonitorAllDto result = await dialogs.SetMonitorAllAsync(body.Enabled, ct);
if (body.Enabled && result.BackfillNeededIds.Count > 0)
{
// Первое включение неразобранных: фоновый разбор списком (python L566: _spawn(_backfill_dialogs)).
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfills(result.BackfillNeededIds);
}
if (body.Enabled)
{
// Аудит включения каналов (этап 10, T1): без имён/содержимого.
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { all = true, count = result.Count }, ct);
}
return Results.Ok(new { ok = true, count = result.Count, enabled = body.Enabled });
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return GatewayError(exception);
}
}
// POST /api/tg/dialogs/backfill-all: «Перечитать» включённые каналы в фоне (tg_routes.py L132136).
private static async Task<IResult> BackfillAllAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
int count = (await dialogs.ListMonitoredIdsAsync(ct)).Count;
if (count > 0)
{
// Ответ — сразу {ok, count}, разбор идёт в фоне (python L580: _spawn(backfill_monitored)).
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleReadRecent();
}
return Results.Ok(new { ok = true, count });
}
// POST /api/tg/dialogs/{dialog_id}/monitor: вкл/выкл мониторинг канала (tg_routes.py L139142; L536546).
private static async Task<IResult> SetMonitorAsync(string dialog_id, TgMonitorBody body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
try
{
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
TelegramMonitorToggleDto result = await dialogs.SetMonitorAsync(dialog_id, body.Enabled, ct);
if (result.BackfillNeeded)
{
// Первое включение неразобранного канала: фоновый разбор (python L546: _spawn(backfill_dialog)).
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfill(dialog_id);
}
if (result.Enabled)
{
// Аудит включения канала (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { dialogId = dialog_id }, ct);
}
return Results.Ok(new { ok = true, enabled = result.Enabled });
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return GatewayError(exception);
}
}
// POST /api/tg/dialogs/{dialog_id}/backfill: разбор одного диалога (tg_routes.py L145148; L349390).
// Сервер-only эндпоинт (фронт не вызывает, api-map §3.3 L139/п.9): первый разбор/догон одного канала.
private static async Task<IResult> BackfillDialogAsync(string dialog_id, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
try
{
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
int processed = await dialogs.BackfillOneAsync(dialog_id, force: false, ct);
return Results.Ok(new { ok = true, processed });
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return GatewayError(exception);
}
}
// POST /api/tg/dialogs/preview: последние сообщения диалога (tg_routes.py L151153; dialog_messages L583620).
private static async Task<IResult> PreviewAsync(TgPreviewBody body, HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
int limit = Math.Clamp(body.Limit ?? PreviewDefaultLimit, PreviewLimitMin, PreviewLimitMax);
IReadOnlyList<TelegramMessageDto> items = await dialogs.PreviewAsync(body.DialogId, limit, ct);
return Results.Ok(new { items });
}
// Читает глобальные ключи приложения; null — заданы не оба (ветка 400 «Ключи Telegram не заданы оператором»).
// context: Контекст запроса.
// ct: Токен отмены.
// Возвращает: Снимок ключей либо null.
private static async Task<TgKeysSnapshot?> ReadKeysOrNullAsync(HttpContext context, CancellationToken ct)
{
TelegramKeysService keysService = context.RequestServices.GetRequiredService<TelegramKeysService>();
TgKeysSnapshot keys = await keysService.GetAsync(ct);
return keys.KeysSet ? keys : null;
}
// 400 {detail} по ошибке гейта: канонический detail RPC либо «Telegram не подключён» (Ruling 7/8).
// exception: Исключение вызова гейта (RpcException домена/транспорта, прочее).
// Возвращает: 400-ответ с текстом причины.
private static IResult GatewayError(Exception exception)
{
return EndpointResults.BadRequest(GatewayErrorText(exception));
}
/// <summary>
/// Текст причины ошибки гейта для {detail} (канонические тексты telegram-service 1:1, Ruling 7).
/// </summary>
/// <param name="exception">Исключение вызова гейта.</param>
/// <returns>Текст причины.</returns>
public static string GatewayErrorText(Exception exception)
{
return exception switch
{
// Доменная RPC-ошибка: detail от telegram-service («Неверный код», «Telegram не подключён», …).
global::Grpc.Core.RpcException rpc when !string.IsNullOrEmpty(rpc.Status.Detail) => rpc.Status.Detail,
// Недоступность/прочий транспорт — «не подключён» (GrpcTelegramClient нормализует, Ruling 7).
_ => NotConnectedDetail,
};
}
/// <summary>
/// Русская форма типа источника на границе эндпоинта (заметка Task 1, api-map §4.8 L349).
/// </summary>
/// <remarks>Каталог ядра хранит EN-канон (channel/group/forum/chat); наружу (вкладка «Каналы», фильтр по типу)
/// — русские подписи python (_kind_of L461466: «канал»/«группа»/«чат»; форум отображается как группа).</remarks>
/// <param name="kind">Тип источника (EN-канон каталога либо уже русская подпись).</param>
/// <returns>Русская подпись: channel→«канал», group/forum→«группа», chat→«чат»; иное — как есть.</returns>
public static string ToRussianDialogType(string kind)
{
return kind switch
{
"channel" => "канал",
"group" or "forum" => "группа",
"chat" => "чат",
_ => kind,
};
}
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
// Безопасный разбор сохранённого api_id: невалидное значение — не исключение, а false
// (400 на HTTP-слое; Security review: int.Parse по пользовательской настройке давал FormatException).
// apiId: Строка api_id из настроек (цифры 6..9, см. SettingsService).
// value: Разобранное значение (валидно только при возврате true).
// Возвращает: True — значение корректно и влезает в int.
private static bool TryParseApiId(string apiId, out int value)
{
return int.TryParse(apiId, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out value);
}
}
@@ -0,0 +1,88 @@
using Deal.Api.Http;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Net.Codecrete.QrCodeGenerator;
namespace Deal.Api.Endpoints;
/// <summary>
/// GET /api/tg/qr-image: SVG QR-кода входа (tg_routes.py L3039; Ruling 8).
/// </summary>
/// <remarks>
/// Фронт рисует QR картинкой: <c>&lt;img src="/api/tg/qr-image?t=N"&gt;</c> (store.js tg-флоу; api-map §3.3 L133).
/// Активен только в фазе входа «qr» (живой статус гейта); иначе — 404 «QR не активен — начните вход по QR»
/// (глобальная строка контракта, python L34). SVG генерирует Net.Codecrete.QrCodeGenerator (SVG-first, без
/// внешних растровых зависимостей — план Task 14/Tech Stack); border=1 как python (border=1, L22). Заголовки —
/// no-store + Content-Disposition: inline (python L3738: свежий QR на каждый запрос, не кэшировать). Сессия
/// обязательна: 401 {detail} (Ruling 10).
/// </remarks>
public static class TelegramQrImageEndpoint
{
// Префикс группы /api/tg (общий с TelegramEndpoints).
private const string QrGroupPrefix = "/api/tg";
// Путь SVG QR-кода (GET; фронт добавляет ?t=N от кэша).
private const string QrImagePath = "/qr-image";
// OpenAPI-тег группы (в прототипе роутер tg — tg_routes.py).
private const string QrImageOpenApiTag = "telegram";
// Деталь 404: QR не активен (python L34, глобальная строка контракта).
private const string QrNotActiveDetail = "QR не активен — начните вход по QR";
// Media-type SVG-ответа (python L37: image/svg+xml).
private const string SvgMediaType = "image/svg+xml";
// Ширина рамки (quiet zone) QR в модулях (python L22: qrcode border=1).
private const int QrBorderModules = 1;
/// <summary>
/// Регистрирует GET /api/tg/qr-image.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapTelegramQrImageEndpoint(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(QrGroupPrefix).WithTags(QrImageOpenApiTag);
group.MapGet(QrImagePath, QrImageAsync);
return app;
}
// GET /api/tg/qr-image: SVG QR-кода фазы входа «qr» (tg_routes.py L3039).
private static async Task<IResult> QrImageAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
// Активен только в фазе «qr» живого статуса telegram-service; сервис недоступен/фаза иная — 404 (python L3334).
TelegramAccountStatusDto live;
try
{
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
live = await gateway.StatusAsync(ct);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return EndpointResults.NotFound(QrNotActiveDetail);
}
if (!string.Equals(live.Phase, "qr", StringComparison.Ordinal) || string.IsNullOrEmpty(live.QrUrl))
{
return EndpointResults.NotFound(QrNotActiveDetail);
}
QrCode qr = QrCode.EncodeText(live.QrUrl, QrCode.Ecc.Medium);
string svg = qr.ToSvgString(QrBorderModules);
// Свежий QR на каждый запрос (не кэшировать); inline — как python L3738.
context.Response.Headers.CacheControl = "no-store";
context.Response.Headers.ContentDisposition = "inline";
return Results.Text(svg, SvgMediaType);
}
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
}