From 413eaac48c5cac21d81d1f03bc614d0e779a1b8a Mon Sep 17 00:00:00 2001 From: Rustam Khalimov Date: Fri, 11 Sep 2026 13:08:47 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=8B=D0=BD=D0=B5=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?=D1=83=D1=81=D0=BB=D0=BE=D0=B2=D0=B8=D1=8F-=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B8=D0=BA=D0=B0=D1=82=D1=8B=20=D0=B2=20extension-?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HasUser из 13 endpoint-файлов сведён в AuthHelpers.HasUser; 15 приватных предикатов заменены extension-методами с удалением дублирующих приватных методов: IsCommunicationFailure, IsTransportFailure, IsPrivateEndpoint, IsConfigured, IsTrue, IsExpired, IsFailedLogin/IsSuccessfulLogin, HasChanges, HasBudget, HasAnyTerm, IsCurrencyLetter, IsEmojiCodePoint, ContainsFooterHint, IsTypeLabel. --- .../Deal.Api/Endpoints/AiSuggestEndpoints.cs | 8 +- .../Endpoints/CardDetailsEndpoints.cs | 29 +- src/core/Deal.Api/Endpoints/CardsEndpoints.cs | 32 +- .../Deal.Api/Endpoints/ContainersEndpoints.cs | 21 +- .../Deal.Api/Endpoints/DiscoveryEndpoints.cs | 30 +- .../Endpoints/FilterTesterEndpoints.cs | 6 +- src/core/Deal.Api/Endpoints/MlEndpoints.cs | 14 +- .../Deal.Api/Endpoints/PipelineEndpoints.cs | 16 +- src/core/Deal.Api/Endpoints/RatesEndpoints.cs | 8 +- .../Deal.Api/Endpoints/SettingsEndpoints.cs | 260 ++-- .../Deal.Api/Endpoints/StorageEndpoints.cs | 8 +- .../Deal.Api/Endpoints/TelegramEndpoints.cs | 1066 ++++++++--------- .../Endpoints/TelegramQrImageEndpoint.cs | 6 +- src/core/Deal.Api/Http/AuthHelpers.cs | 7 + .../Integrations/AiConnectionChecker.cs | 49 +- .../Integrations/RpcExceptionExtensions.cs | 17 + .../Integrations/ServiceHealthProbe.cs | 8 +- .../Storage/FileStorageRegistrar.cs | 16 +- .../Storage/MinioStorageOptionsExtensions.cs | 19 + .../Integrations/Storage/StringExtensions.cs | 17 + .../Integrations/UriExtensions.cs | 55 + .../DiscoveryTaskPatchExtensions.cs | 18 + .../Application/DiscoveryTasksService.cs | 12 +- .../Application/BudgetNormalizer.cs | 12 +- .../Application/CharExtensions.cs | 15 + .../ColumnRules/BudgetRangeDtoExtensions.cs | 21 + .../Application/ColumnRules/ColumnMatcher.cs | 49 +- .../ColumnRules/TermListExtensions.cs | 30 + .../Application/Parse/CodePointExtensions.cs | 18 + .../Application/Parse/MessageTextCleaner.cs | 13 +- .../Application/Parse/StringExtensions.cs | 33 + .../Application/Parse/SummaryComposer.cs | 30 +- .../Application/AuditRecordDtoExtensions.cs | 35 + .../Application/InviteDtoExtensions.cs | 13 + .../Application/InvitesService.cs | 9 +- .../Application/SuspiciousActivityService.cs | 32 +- .../Deal.Ml/Model/LabelExtensions.cs | 17 + .../Deal.Ml/Model/OnlineNaiveBayes.cs | 7 +- .../Telegram/ExceptionExtensions.cs | 21 + .../Deal.Telegram/TelegramServiceImpl.cs | 7 +- 40 files changed, 1091 insertions(+), 993 deletions(-) create mode 100644 src/core/Deal.Infrastructure/Integrations/RpcExceptionExtensions.cs create mode 100644 src/core/Deal.Infrastructure/Integrations/Storage/MinioStorageOptionsExtensions.cs create mode 100644 src/core/Deal.Infrastructure/Integrations/Storage/StringExtensions.cs create mode 100644 src/core/Deal.Infrastructure/Integrations/UriExtensions.cs create mode 100644 src/core/Deal.Modules.Discovery/Application/DiscoveryTaskPatchExtensions.cs create mode 100644 src/core/Deal.Modules.Kanban/Application/CharExtensions.cs create mode 100644 src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetRangeDtoExtensions.cs create mode 100644 src/core/Deal.Modules.Kanban/Application/ColumnRules/TermListExtensions.cs create mode 100644 src/core/Deal.Modules.Pipeline/Application/Parse/CodePointExtensions.cs create mode 100644 src/core/Deal.Modules.Pipeline/Application/Parse/StringExtensions.cs create mode 100644 src/core/Deal.Modules.Tenants/Application/AuditRecordDtoExtensions.cs create mode 100644 src/core/Deal.Modules.Tenants/Application/InviteDtoExtensions.cs create mode 100644 src/ml-service/Deal.Ml/Model/LabelExtensions.cs create mode 100644 src/telegram-service/Deal.Telegram/Telegram/ExceptionExtensions.cs diff --git a/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs b/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs index 7ed9196..5e46b28 100644 --- a/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs @@ -61,7 +61,7 @@ public static class AiSuggestEndpoints // «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3). private static async Task SuggestColumnsAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -84,7 +84,7 @@ public static class AiSuggestEndpoints // Ответ — результат порта 1:1: {ok:true, keywords:[…]} (≤60) либо {ok:false, reason} (HTTP 200). private static async Task SuggestKeywordsAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -93,8 +93,4 @@ public static class AiSuggestEndpoints 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; } diff --git a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs index 1b4ab96..3b61fa7 100644 --- a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs @@ -110,7 +110,7 @@ public static class CardDetailsEndpoints // POST /api/cards: ручное создание «локальной» карточки. Ответ — созданная карточка. private static async Task CreateCardAsync(CreateCardRequest body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -126,7 +126,7 @@ public static class CardDetailsEndpoints // POST /api/cards/take {cardId}: «взять в работу» — перенос карточки в planned. Ответ — карточка. private static async Task TakeCardAsync(TakeCardRequest body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -141,7 +141,7 @@ public static class CardDetailsEndpoints // POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено». private static async Task ClearRejectedAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -156,7 +156,7 @@ public static class CardDetailsEndpoints // (budget:null, stack:null) не теряется типизированным биндингом. Ответ — обновлённая карточка. private static async Task PatchCardAsync(string cardId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -177,7 +177,7 @@ public static class CardDetailsEndpoints // POST /api/cards/{cardId}/links {name?,url}: добавить ссылку. Ответ — карточка. private static async Task AddLinkAsync(string cardId, CardLinkRequest body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -201,7 +201,7 @@ public static class CardDetailsEndpoints // DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка. private static async Task RemoveLinkAsync(string cardId, string linkId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -218,7 +218,7 @@ public static class CardDetailsEndpoints // имя/ContentType/поток/длина → CardsService.AddFileAsync. Ранний null — гонка (404). private static async Task UploadFilesAsync(string cardId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -258,7 +258,7 @@ public static class CardDetailsEndpoints // с Content-Length/Content-Type из дескриптора; Content-Disposition attachment, имя без кавычек. private static async Task DownloadFileAsync(string cardId, string fileId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -307,7 +307,7 @@ public static class CardDetailsEndpoints // DELETE /api/cards/{cardId}/files/{fileId}: открепить файл. Ответ — карточка. private static async Task RemoveFileAsync(string cardId, string fileId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -322,7 +322,7 @@ public static class CardDetailsEndpoints // POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка. private static async Task SetReminderAsync(string cardId, ReminderSetRequest body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -347,7 +347,7 @@ public static class CardDetailsEndpoints // DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка. private static async Task ClearReminderAsync(string cardId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -361,7 +361,7 @@ public static class CardDetailsEndpoints // POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка. private static async Task SnoozeReminderAsync(string cardId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -424,9 +424,4 @@ public static class CardDetailsEndpoints return null; } } - - // Разрешена ли сессия запроса. - // context: Контекст запроса. - // Возвращает: True — сессия есть. - private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null; } diff --git a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs index 5dac441..f15ee23 100644 --- a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs @@ -89,7 +89,7 @@ public static class CardsEndpoints // Параметр col принят как алиас containerId (совместимость со старым фронтом). private static async Task ListCardsAsync(string? containerId, string? col, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -108,7 +108,7 @@ public static class CardsEndpoints // GET /api/cards/counts: плоская wire-форма счётчиков {new, <col>:{count,new}, learning, ml, ai} (L161–163, §4.1 L257). private static async Task CountsAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -132,7 +132,7 @@ public static class CardsEndpoints // GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168). private static async Task GetCardAsync(string cardId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -147,7 +147,7 @@ public static class CardsEndpoints // POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}. private static async Task MarkAllSeenAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -160,7 +160,7 @@ public static class CardsEndpoints // POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {ok:true}. private static async Task MarkColSeenAsync(MarkColBody body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -184,7 +184,7 @@ public static class CardsEndpoints // несуществующем контейнере, 404 — карточки нет. private static async Task MoveAsync(string cardId, MoveBody body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -214,7 +214,7 @@ public static class CardsEndpoints // POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203–207); ответ {ok:true}; 404. private static async Task TrashAsync(string cardId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -234,7 +234,7 @@ public static class CardsEndpoints // POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404. private static async Task RestoreAsync(string cardId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -254,7 +254,7 @@ public static class CardsEndpoints // DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217–221); ответ {ok:true}; 404. private static async Task DeleteAsync(string cardId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -274,7 +274,7 @@ public static class CardsEndpoints // POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400. private static async Task ClearColAsync(ClearColBody body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -289,7 +289,7 @@ public static class CardsEndpoints // POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404. private static async Task AddCommentAsync(string cardId, CommentBody body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -321,7 +321,7 @@ public static class CardsEndpoints // ct: Токен отмены. private static async Task ReclassifyAsync(ReclassifyBody? body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -339,7 +339,7 @@ public static class CardsEndpoints // ct: Токен отмены. private static async Task ReclassifyOneAsync(string cardId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -416,7 +416,7 @@ public static class CardsEndpoints // GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254–256). Ответ {leads, messages: []}. private static async Task SearchAsync(string? q, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -440,8 +440,4 @@ public static class CardsEndpoints ContainersService containers = context.RequestServices.GetRequiredService(); return await containers.GetAsync(col, ct) is not null; } - - // Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст). - // context: Контекст запроса. - private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null; } diff --git a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs index ac478c8..1655bb8 100644 --- a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs @@ -62,7 +62,7 @@ public static class ContainersEndpoints // GET /api/containers?space=: список контейнеров пространства (или всех) со счётчиками. private static async Task ListContainersAsync(string? space, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -74,7 +74,7 @@ public static class ContainersEndpoints // POST /api/containers: создать контейнер; ответ {id}. private static async Task CreateContainerAsync(ContainerCreateRequest body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -105,7 +105,7 @@ public static class ContainersEndpoints // PATCH /api/containers/{id}: частичное обновление; ответ {id}; 404 «Контейнер не найден». private static async Task PatchContainerAsync(string containerId, JsonElement body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -165,7 +165,7 @@ public static class ContainersEndpoints // POST /api/containers/{id}/accept: принять ИИ-предложение (suggested=false). private static async Task AcceptSuggestedAsync(string containerId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -185,7 +185,7 @@ public static class ContainersEndpoints // DELETE /api/containers/{id}: удалить контейнер; карточки → «Неразобранное» новыми. private static async Task DeleteContainerAsync(string containerId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -201,7 +201,7 @@ public static class ContainersEndpoints // POST /api/containers/reorder: порядок контейнеров пространства; ответ {ok:true}. private static async Task ReorderContainersAsync(OrderBody body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -219,7 +219,7 @@ public static class ContainersEndpoints // GET /api/containers/state: свёрнутость/ширина всех колонок (colState). private static async Task GetColumnsStateAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -239,7 +239,7 @@ public static class ContainersEndpoints // PATCH /api/containers/{id}/state: merge патча в состояние колонки; ответ — состояние этой колонки. private static async Task PatchColumnStateAsync(string containerId, ColStateBody body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -296,9 +296,4 @@ public static class ContainersEndpoints return wire; } - - // Разрешена ли сессия запроса. - // context: Контекст запроса. - // Возвращает: True — сессия есть. - private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null; } diff --git a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs index e6be05e..f1b2dc4 100644 --- a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs @@ -127,7 +127,7 @@ public static class DiscoveryEndpoints // GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141–143). private static async Task ListTasksAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -140,7 +140,7 @@ public static class DiscoveryEndpoints // POST /api/discovery/tasks: создать задачу поиска (create_task L146–151; дефолты — в сервисе). private static async Task CreateTaskAsync(DiscoveryTaskCreateBody body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -160,7 +160,7 @@ public static class DiscoveryEndpoints // PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154–161; 404/400). private static async Task PatchTaskAsync(string task_id, DiscoveryTaskPatchBody body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -180,7 +180,7 @@ public static class DiscoveryEndpoints // DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164–168). private static async Task DeleteTaskAsync(string task_id, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -193,7 +193,7 @@ public static class DiscoveryEndpoints // POST /api/discovery/tasks/{task_id}/start: запуск поиска (start_task L171–179; пустые ключи → 400). private static async Task StartTaskAsync(string task_id, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -213,7 +213,7 @@ public static class DiscoveryEndpoints // POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181–187). private static async Task PauseTaskAsync(string task_id, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -230,7 +230,7 @@ public static class DiscoveryEndpoints // Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины. private static async Task GenerateKeywordsAsync(string task_id, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -277,7 +277,7 @@ public static class DiscoveryEndpoints // new|review|joined|rejected (list_candidates L216–224; невалидный статус — пустой список). private static async Task ListCandidatesAsync(string task_id, string? status, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -299,7 +299,7 @@ public static class DiscoveryEndpoints // mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины. private static async Task JoinCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -355,7 +355,7 @@ public static class DiscoveryEndpoints // (reject_candidate L253–264; уже вступившего — нельзя, 400). private static async Task RejectCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -386,7 +386,7 @@ public static class DiscoveryEndpoints // GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269–271). private static async Task ListBlacklistAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -399,7 +399,7 @@ public static class DiscoveryEndpoints // DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274–277). private static async Task RemoveBlacklistAsync(string dialog_id, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -412,7 +412,7 @@ public static class DiscoveryEndpoints // GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282–285), события от новых к старым. private static async Task TaskLogAsync(string task_id, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -533,8 +533,4 @@ public static class DiscoveryEndpoints AutoJoin = body.AutoJoin, }; } - - // Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст). - // context: Контекст запроса. - private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null; } diff --git a/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs b/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs index 5c2f8ee..61a44e6 100644 --- a/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs @@ -45,7 +45,7 @@ public static class FilterTesterEndpoints // POST /api/admin/check-message: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267–284). private static async Task CheckAsync(CheckMessageRequest body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -72,8 +72,4 @@ public static class FilterTesterEndpoints passed = true, }); } - - // Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст). - // context: Контекст запроса. - private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null; } diff --git a/src/core/Deal.Api/Endpoints/MlEndpoints.cs b/src/core/Deal.Api/Endpoints/MlEndpoints.cs index a4e24ee..b3642e0 100644 --- a/src/core/Deal.Api/Endpoints/MlEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/MlEndpoints.cs @@ -78,7 +78,7 @@ public static class MlEndpoints // GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L66–75). private static async Task StatusAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -90,7 +90,7 @@ public static class MlEndpoints // POST /api/ml/reset: полный сброс модели + очистка очереди обучения (ml_routes.py L78–81). private static async Task ResetAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -102,7 +102,7 @@ public static class MlEndpoints // POST /api/ml/predict: проверка ML на тексте (ml_routes.py L84–90). private static async Task PredictAsync(MlPredictRequest body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -137,7 +137,7 @@ public static class MlEndpoints // POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112–134). private static async Task CandidatesAsync(MlCandidatesRequest body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -150,7 +150,7 @@ public static class MlEndpoints // POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137–171). private static async Task ApplyAsync(MlApplyRequest body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -175,8 +175,4 @@ public static class MlEndpoints leadId = result.LeadId, }); } - - // Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст). - // context: Контекст запроса. - private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null; } diff --git a/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs b/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs index 6a5e602..e1191ae 100644 --- a/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs @@ -77,7 +77,7 @@ public static class PipelineEndpoints // GET /api/pipeline/stats: сводка вкладки {queue:{new,ai,total}, rejected} (processing_routes.py L17–20, stats L315–320). private static async Task StatsAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -91,7 +91,7 @@ public static class PipelineEndpoints // rejected_count L201–202. limit — дефолт 100, clamp 1..500 делает сервис (ListQueueAsync). private static async Task QueueAsync(int? limit, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -108,7 +108,7 @@ public static class PipelineEndpoints // первыми; offset ≥ 0, limit 1..500 (clamp в сервисе), значения эхом в ответе {items,total,offset,limit}. private static async Task RejectedAsync(string? q, int? offset, int? limit, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -121,7 +121,7 @@ public static class PipelineEndpoints // POST /api/pipeline/rejected/clear: полная безвозвратная очистка отсева (processing_routes.py L45–49, clear_all L120–125). private static async Task ClearAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -135,7 +135,7 @@ public static class PipelineEndpoints // Прототип не проверяет наличие записи — 404 не шлём (план Task 9 L444; Ruling 10 «always ok»). private static async Task DeleteAsync(string rejId, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -151,7 +151,7 @@ public static class PipelineEndpoints // прототипом); записи нет — 404 «Запись не найдена» (текст 404 — слой эндпоинтов). private static async Task ReturnAsync(string rejId, ReturnReasonRequest body, HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -167,8 +167,4 @@ public static class PipelineEndpoints ? 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; } diff --git a/src/core/Deal.Api/Endpoints/RatesEndpoints.cs b/src/core/Deal.Api/Endpoints/RatesEndpoints.cs index 37dbde9..7057aeb 100644 --- a/src/core/Deal.Api/Endpoints/RatesEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/RatesEndpoints.cs @@ -47,7 +47,7 @@ public static class RatesEndpoints // GET /api/rates: текущий кэш курсов тенанта (+ ленивый фоновый refresh при необходимости). private static async Task GetRatesAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -70,7 +70,7 @@ public static class RatesEndpoints // POST /api/rates/refresh: принудительное обновление; ответ {ok, rates} (1:1 settings_routes.py L229–232). private static async Task RefreshRatesAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -82,8 +82,4 @@ public static class RatesEndpoints 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; } diff --git a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs index dbcca8f..f722173 100644 --- a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs @@ -1,132 +1,128 @@ -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; - -/// -/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6). -/// -/// -/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3); -/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения -/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию: -/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH -/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400). -/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое -/// обновление кэша курсов (, Ruling 6); пересчёт карточек при смене -/// targetCurrency/conversionOn выполняет сам SettingsService через порт -/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12). -/// -/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не -/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость -/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса -/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401, -/// а не 500 при резолве. -/// -/// -public static class SettingsEndpoints -{ - private const string ApiGroupPrefix = "/api"; - private const string SettingsPath = "/settings"; - private const string SettingsOpenApiTag = "settings"; - private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом"; - - /// - /// Регистрирует GET/PATCH /api/settings. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - 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 GetSettingsAsync(HttpContext context, CancellationToken ct) - { - if (!HasUser(context)) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - SettingsService settingsService = context.RequestServices.GetRequiredService(); - return Results.Ok(await settingsService.GetPublicAsync(ct)); - } - - // PATCH /api/settings: частичное обновление настроек; ответ — полный снимок после применения. - private static async Task PatchSettingsAsync(HttpContext context, CancellationToken ct) - { - if (!HasUser(context)) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - // Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт). - Dictionary? body; - try - { - body = await JsonSerializer.DeserializeAsync>( - 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(); - PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct); - - // Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся. - await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct); - - // Смена источника курсов в PATCH (settings_routes.py L188–189) — фоновое обновление кэша - // курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource. - if (ShouldScheduleRatesRefresh(body)) - { - context.RequestServices.GetRequiredService().Schedule(); - } - - return Results.Ok(result); - } - - // Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188). - // body: Тело PATCH — публичные ключи §4.6. - // Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка). - private static bool ShouldScheduleRatesRefresh(Dictionary 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; -} +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; + +/// +/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6). +/// +/// +/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3); +/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения +/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию: +/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH +/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400). +/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое +/// обновление кэша курсов (, Ruling 6); пересчёт карточек при смене +/// targetCurrency/conversionOn выполняет сам SettingsService через порт +/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12). +/// +/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не +/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость +/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса +/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401, +/// а не 500 при резолве. +/// +/// +public static class SettingsEndpoints +{ + private const string ApiGroupPrefix = "/api"; + private const string SettingsPath = "/settings"; + private const string SettingsOpenApiTag = "settings"; + private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом"; + + /// + /// Регистрирует GET/PATCH /api/settings. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + 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 GetSettingsAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + SettingsService settingsService = context.RequestServices.GetRequiredService(); + return Results.Ok(await settingsService.GetPublicAsync(ct)); + } + + // PATCH /api/settings: частичное обновление настроек; ответ — полный снимок после применения. + private static async Task PatchSettingsAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + // Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт). + Dictionary? body; + try + { + body = await JsonSerializer.DeserializeAsync>( + 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(); + PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct); + + // Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся. + await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct); + + // Смена источника курсов в PATCH (settings_routes.py L188–189) — фоновое обновление кэша + // курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource. + if (ShouldScheduleRatesRefresh(body)) + { + context.RequestServices.GetRequiredService().Schedule(); + } + + return Results.Ok(result); + } + + // Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188). + // body: Тело PATCH — публичные ключи §4.6. + // Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка). + private static bool ShouldScheduleRatesRefresh(Dictionary 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, + }; + } +} diff --git a/src/core/Deal.Api/Endpoints/StorageEndpoints.cs b/src/core/Deal.Api/Endpoints/StorageEndpoints.cs index 34ff87a..bea2e32 100644 --- a/src/core/Deal.Api/Endpoints/StorageEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/StorageEndpoints.cs @@ -58,7 +58,7 @@ public static class StorageEndpoints // PumpOnceAsync (сбой не роняет тик) → SSE new_card по созданным карточкам → queue. Формы — 1:1 с прототипом. private static async Task AdminTickAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -75,7 +75,7 @@ public static class StorageEndpoints // is_ready() → ready) — кнопка Settings фронта показывает ошибку по ready (store.js L1883–1889). private static async Task FtsRebuildAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -84,8 +84,4 @@ public static class StorageEndpoints 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; } diff --git a/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs b/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs index d738bc6..78caaa3 100644 --- a/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs @@ -1,535 +1,531 @@ -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; - -/// -/// Эндпоинты /api/tg: статус, веб-авторизация (phone/QR/код/2FA/logout), диалоги и мониторинг (Ruling 8, api-map §3.3). -/// -/// -/// Тела ответов 1:1 с прототипом backend/app/routers/tg_routes.py: -/// status — §4.9 (собирает ); start-phone/send-code/send-password — {phase}; -/// start-qr — {phase, qrUrl}; logout — {ok:true}; dialogs — {items:[диалог §4.8]} (type — русская -/// форма на границе: канал/группа/чат, заметка Task 1); refresh — {ok, count} либо {ok:false, -/// reason:"not-connected", count:0} (мягкая ветка L119–120); monitor/monitor-all/backfill-all/preview — как в -/// §3.3. Ошибки гейта (недоступный сервис/доменный отказ RPC) — 400 {detail} с канонической причиной -/// (Ruling 7/8). Фоновый первый разбор при включении мониторинга и «Перечитать» — -/// (python-_spawn L546/L566/L580). Все эндпоинты требуют сессию: 401 {detail} (Ruling 10). Сервисы резолвятся -/// из RequestServices ПОСЛЕ проверки сессии (scoped — TenantDbContext схемы тенанта, паттерн SettingsEndpoints). -/// -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; - - /// - /// Регистрирует группу /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). - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - 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 L63–65; форма §4.9). - private static async Task StatusAsync(HttpContext context, CancellationToken ct) - { - if (!HasUser(context)) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - TgStatusService statusService = context.RequestServices.GetRequiredService(); - return Results.Ok(await statusService.GetAsync(ct)); - } - - // POST /api/tg/start-phone: запросить код по номеру (tg_routes.py L68–74; python L134–147). - private static async Task 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(); - 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 L77–83; python qr_start L286–300). - private static async Task 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(); - 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 L86–94; python submit_code L149–166). - private static async Task SendCodeAsync(TgSendCodeRequest body, HttpContext context, CancellationToken ct) - { - if (!HasUser(context)) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - ITelegramGateway gateway = context.RequestServices.GetRequiredService(); - 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 L97–103; python submit_password L168–176). - private static async Task SendPasswordAsync(TgSendPasswordRequest body, HttpContext context, CancellationToken ct) - { - if (!HasUser(context)) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - ITelegramGateway gateway = context.RequestServices.GetRequiredService(); - 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 L106–109; python disconnect L189–207). - private static async Task LogoutAsync(HttpContext context, CancellationToken ct) - { - if (!HasUser(context)) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - ITelegramGateway gateway = context.RequestServices.GetRequiredService(); - 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 L112–114; list_dialogs L521–534). - private static async Task DialogsAsync(HttpContext context, CancellationToken ct) - { - if (!HasUser(context)) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DialogsService dialogs = context.RequestServices.GetRequiredService(); - IReadOnlyList 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(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 L117–122). - private static async Task DialogsRefreshAsync(HttpContext context, CancellationToken ct) - { - if (!HasUser(context)) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DialogsService dialogs = context.RequestServices.GetRequiredService(); - ITelegramGateway gateway = context.RequestServices.GetRequiredService(); - - // 1:1 tg_routes.py L119–120: аккаунт не подключён (или сервис недоступен — 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 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 L125–129; L548–567). - private static async Task MonitorAllAsync(TgMonitorBody body, HttpContext context, CancellationToken ct) - { - if (!HasUser(context)) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - DialogsService dialogs = context.RequestServices.GetRequiredService(); - TelegramMonitorAllDto result = await dialogs.SetMonitorAllAsync(body.Enabled, ct); - if (body.Enabled && result.BackfillNeededIds.Count > 0) - { - // Первое включение неразобранных: фоновый разбор списком (python L566: _spawn(_backfill_dialogs)). - context.RequestServices.GetRequiredService().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 L132–136). - private static async Task BackfillAllAsync(HttpContext context, CancellationToken ct) - { - if (!HasUser(context)) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DialogsService dialogs = context.RequestServices.GetRequiredService(); - int count = (await dialogs.ListMonitoredIdsAsync(ct)).Count; - if (count > 0) - { - // Ответ — сразу {ok, count}, разбор идёт в фоне (python L580: _spawn(backfill_monitored)). - context.RequestServices.GetRequiredService().ScheduleReadRecent(); - } - - return Results.Ok(new { ok = true, count }); - } - - // POST /api/tg/dialogs/{dialog_id}/monitor: вкл/выкл мониторинг канала (tg_routes.py L139–142; L536–546). - private static async Task SetMonitorAsync(string dialog_id, TgMonitorBody body, HttpContext context, CancellationToken ct) - { - if (!HasUser(context)) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - DialogsService dialogs = context.RequestServices.GetRequiredService(); - TelegramMonitorToggleDto result = await dialogs.SetMonitorAsync(dialog_id, body.Enabled, ct); - if (result.BackfillNeeded) - { - // Первое включение неразобранного канала: фоновый разбор (python L546: _spawn(backfill_dialog)). - context.RequestServices.GetRequiredService().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 L145–148; L349–390). - // Сервер-only эндпоинт (фронт не вызывает, api-map §3.3 L139/п.9): первый разбор/догон одного канала. - private static async Task BackfillDialogAsync(string dialog_id, HttpContext context, CancellationToken ct) - { - if (!HasUser(context)) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - DialogsService dialogs = context.RequestServices.GetRequiredService(); - 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 L151–153; dialog_messages L583–620). - private static async Task PreviewAsync(TgPreviewBody body, HttpContext context, CancellationToken ct) - { - if (!HasUser(context)) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DialogsService dialogs = context.RequestServices.GetRequiredService(); - int limit = Math.Clamp(body.Limit ?? PreviewDefaultLimit, PreviewLimitMin, PreviewLimitMax); - IReadOnlyList items = await dialogs.PreviewAsync(body.DialogId, limit, ct); - return Results.Ok(new { items }); - } - - // Читает глобальные ключи приложения; null — заданы не оба (ветка 400 «Ключи Telegram не заданы оператором»). - // context: Контекст запроса. - // ct: Токен отмены. - // Возвращает: Снимок ключей либо null. - private static async Task ReadKeysOrNullAsync(HttpContext context, CancellationToken ct) - { - TelegramKeysService keysService = context.RequestServices.GetRequiredService(); - 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)); - } - - /// - /// Текст причины ошибки гейта для {detail} (канонические тексты telegram-service 1:1, Ruling 7). - /// - /// Исключение вызова гейта. - /// Текст причины. - 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, - }; - } - - /// - /// Русская форма типа источника на границе эндпоинта (заметка Task 1, api-map §4.8 L349). - /// - /// Каталог ядра хранит EN-канон (channel/group/forum/chat); наружу (вкладка «Каналы», фильтр по типу) - /// — русские подписи python (_kind_of L461–466: «канал»/«группа»/«чат»; форум отображается как группа). - /// Тип источника (EN-канон каталога либо уже русская подпись). - /// Русская подпись: channel→«канал», group/forum→«группа», chat→«чат»; иное — как есть. - 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); - } -} +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; + +/// +/// Эндпоинты /api/tg: статус, веб-авторизация (phone/QR/код/2FA/logout), диалоги и мониторинг (Ruling 8, api-map §3.3). +/// +/// +/// Тела ответов 1:1 с прототипом backend/app/routers/tg_routes.py: +/// status — §4.9 (собирает ); start-phone/send-code/send-password — {phase}; +/// start-qr — {phase, qrUrl}; logout — {ok:true}; dialogs — {items:[диалог §4.8]} (type — русская +/// форма на границе: канал/группа/чат, заметка Task 1); refresh — {ok, count} либо {ok:false, +/// reason:"not-connected", count:0} (мягкая ветка L119–120); monitor/monitor-all/backfill-all/preview — как в +/// §3.3. Ошибки гейта (недоступный сервис/доменный отказ RPC) — 400 {detail} с канонической причиной +/// (Ruling 7/8). Фоновый первый разбор при включении мониторинга и «Перечитать» — +/// (python-_spawn L546/L566/L580). Все эндпоинты требуют сессию: 401 {detail} (Ruling 10). Сервисы резолвятся +/// из RequestServices ПОСЛЕ проверки сессии (scoped — TenantDbContext схемы тенанта, паттерн SettingsEndpoints). +/// +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; + + /// + /// Регистрирует группу /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). + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + 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 L63–65; форма §4.9). + private static async Task StatusAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + TgStatusService statusService = context.RequestServices.GetRequiredService(); + return Results.Ok(await statusService.GetAsync(ct)); + } + + // POST /api/tg/start-phone: запросить код по номеру (tg_routes.py L68–74; python L134–147). + private static async Task StartPhoneAsync(TgStartPhoneRequest body, HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + 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(); + 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 L77–83; python qr_start L286–300). + private static async Task StartQrAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + 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(); + 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 L86–94; python submit_code L149–166). + private static async Task SendCodeAsync(TgSendCodeRequest body, HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + ITelegramGateway gateway = context.RequestServices.GetRequiredService(); + 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 L97–103; python submit_password L168–176). + private static async Task SendPasswordAsync(TgSendPasswordRequest body, HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + ITelegramGateway gateway = context.RequestServices.GetRequiredService(); + 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 L106–109; python disconnect L189–207). + private static async Task LogoutAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + ITelegramGateway gateway = context.RequestServices.GetRequiredService(); + 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 L112–114; list_dialogs L521–534). + private static async Task DialogsAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DialogsService dialogs = context.RequestServices.GetRequiredService(); + IReadOnlyList 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(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 L117–122). + private static async Task DialogsRefreshAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DialogsService dialogs = context.RequestServices.GetRequiredService(); + ITelegramGateway gateway = context.RequestServices.GetRequiredService(); + + // 1:1 tg_routes.py L119–120: аккаунт не подключён (или сервис недоступен — 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 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 L125–129; L548–567). + private static async Task MonitorAllAsync(TgMonitorBody body, HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + DialogsService dialogs = context.RequestServices.GetRequiredService(); + TelegramMonitorAllDto result = await dialogs.SetMonitorAllAsync(body.Enabled, ct); + if (body.Enabled && result.BackfillNeededIds.Count > 0) + { + // Первое включение неразобранных: фоновый разбор списком (python L566: _spawn(_backfill_dialogs)). + context.RequestServices.GetRequiredService().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 L132–136). + private static async Task BackfillAllAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DialogsService dialogs = context.RequestServices.GetRequiredService(); + int count = (await dialogs.ListMonitoredIdsAsync(ct)).Count; + if (count > 0) + { + // Ответ — сразу {ok, count}, разбор идёт в фоне (python L580: _spawn(backfill_monitored)). + context.RequestServices.GetRequiredService().ScheduleReadRecent(); + } + + return Results.Ok(new { ok = true, count }); + } + + // POST /api/tg/dialogs/{dialog_id}/monitor: вкл/выкл мониторинг канала (tg_routes.py L139–142; L536–546). + private static async Task SetMonitorAsync(string dialog_id, TgMonitorBody body, HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + DialogsService dialogs = context.RequestServices.GetRequiredService(); + TelegramMonitorToggleDto result = await dialogs.SetMonitorAsync(dialog_id, body.Enabled, ct); + if (result.BackfillNeeded) + { + // Первое включение неразобранного канала: фоновый разбор (python L546: _spawn(backfill_dialog)). + context.RequestServices.GetRequiredService().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 L145–148; L349–390). + // Сервер-only эндпоинт (фронт не вызывает, api-map §3.3 L139/п.9): первый разбор/догон одного канала. + private static async Task BackfillDialogAsync(string dialog_id, HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + DialogsService dialogs = context.RequestServices.GetRequiredService(); + 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 L151–153; dialog_messages L583–620). + private static async Task PreviewAsync(TgPreviewBody body, HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DialogsService dialogs = context.RequestServices.GetRequiredService(); + int limit = Math.Clamp(body.Limit ?? PreviewDefaultLimit, PreviewLimitMin, PreviewLimitMax); + IReadOnlyList items = await dialogs.PreviewAsync(body.DialogId, limit, ct); + return Results.Ok(new { items }); + } + + // Читает глобальные ключи приложения; null — заданы не оба (ветка 400 «Ключи Telegram не заданы оператором»). + // context: Контекст запроса. + // ct: Токен отмены. + // Возвращает: Снимок ключей либо null. + private static async Task ReadKeysOrNullAsync(HttpContext context, CancellationToken ct) + { + TelegramKeysService keysService = context.RequestServices.GetRequiredService(); + 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)); + } + + /// + /// Текст причины ошибки гейта для {detail} (канонические тексты telegram-service 1:1, Ruling 7). + /// + /// Исключение вызова гейта. + /// Текст причины. + 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, + }; + } + + /// + /// Русская форма типа источника на границе эндпоинта (заметка Task 1, api-map §4.8 L349). + /// + /// Каталог ядра хранит EN-канон (channel/group/forum/chat); наружу (вкладка «Каналы», фильтр по типу) + /// — русские подписи python (_kind_of L461–466: «канал»/«группа»/«чат»; форум отображается как группа). + /// Тип источника (EN-канон каталога либо уже русская подпись). + /// Русская подпись: channel→«канал», group/forum→«группа», chat→«чат»; иное — как есть. + public static string ToRussianDialogType(string kind) + { + return kind switch + { + "channel" => "канал", + "group" or "forum" => "группа", + "chat" => "чат", + _ => kind, + }; + } + + // Безопасный разбор сохранённого 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); + } +} diff --git a/src/core/Deal.Api/Endpoints/TelegramQrImageEndpoint.cs b/src/core/Deal.Api/Endpoints/TelegramQrImageEndpoint.cs index 881774a..ddb241c 100644 --- a/src/core/Deal.Api/Endpoints/TelegramQrImageEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/TelegramQrImageEndpoint.cs @@ -51,7 +51,7 @@ public static class TelegramQrImageEndpoint // GET /api/tg/qr-image: SVG QR-кода фазы входа «qr» (tg_routes.py L30–39). private static async Task QrImageAsync(HttpContext context, CancellationToken ct) { - if (!HasUser(context)) + if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } @@ -81,8 +81,4 @@ public static class TelegramQrImageEndpoint 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; } diff --git a/src/core/Deal.Api/Http/AuthHelpers.cs b/src/core/Deal.Api/Http/AuthHelpers.cs index 38ec187..ad57420 100644 --- a/src/core/Deal.Api/Http/AuthHelpers.cs +++ b/src/core/Deal.Api/Http/AuthHelpers.cs @@ -41,6 +41,13 @@ public static class AuthHelpers public static CurrentUser? GetCurrentUser(this HttpContext context) => context.Items[CurrentUserItemKey] as CurrentUser; + /// + /// Проверяет, разрешена ли для запроса пользовательская сессия. + /// + /// Контекст запроса. + /// True — текущий пользователь установлен. + public static bool HasUser(this HttpContext context) => context.GetCurrentUser() is not null; + /// /// Кладёт оператора в HttpContext.Items. /// diff --git a/src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs b/src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs index ae987b2..27409b2 100644 --- a/src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs +++ b/src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using System.Net; using System.Net.Http.Headers; using Deal.Modules.Settings.Application; using Deal.Modules.Settings.Application.Models; @@ -128,7 +127,7 @@ public sealed class AiConnectionChecker : IAiConnectionChecker // Private/loopback/link-local литералы и localhost запрещены для не-local провайдеров (локальные // провайдеры — ветка IsLocal выше, HTTP для них не выполняется вовсе). DNS-имена не резолвятся // здесь (полный egress-контроль с резолвом — на уровне сетевого периметра/прокси). - if (IsPrivateEndpoint(modelsUri)) + if (modelsUri.IsPrivateEndpoint()) { return BuildResult(request, name, ok: false, message: PrivateEndpointNotAllowedMessage); } @@ -247,50 +246,8 @@ public sealed class AiConnectionChecker : IAiConnectionChecker httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); } - // Проверяет, указывает ли URL на приватный/loopback/link-local адрес (SSRF-гейт). - // Распознаются IP-литералы (IPv4/IPv6) и имя localhost; DNS-имена считаются публичными - // (полный egress-контроль с резолвом выполняется на сетевом периметре). - // uri: Абсолютный http(s)-адрес. - // Возвращает: True — адрес приватный/локальный (HTTP к нему запрещён). - private static bool IsPrivateEndpoint(Uri uri) - { - string host = uri.Host; - if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (!IPAddress.TryParse(host, out IPAddress? address)) - { - return false; // DNS-имя — резолв вне этого слоя - } - - if (address.IsIPv4MappedToIPv6) - { - address = address.MapToIPv4(); - } - - if (IPAddress.IsLoopback(address)) - { - return true; - } - - if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) - { - byte[] bytes = address.GetAddressBytes(); - return bytes[0] == 10 - || (bytes[0] == 172 && bytes[1] is >= 16 and <= 31) - || (bytes[0] == 192 && bytes[1] == 168) - || bytes[0] == 169 && bytes[1] == 254 // link-local (включая 169.254.169.254 metadata) - || bytes[0] == 0; - } - - // IPv6: уникальные локальные (fc00::/7) и link-local (fe80::/10). - byte[] v6 = address.GetAddressBytes(); - return (v6[0] & 0xFE) == 0xFC || (v6[0] == 0xFE && (v6[1] & 0xC0) == 0x80); - } - /// Исключение HTTP-слоя. - /// Человекочитаемый текст причины. + // Исключение HTTP-слоя. + // Возвращает: Человекочитаемый текст причины. private static string ConnectionErrorDetail(HttpRequestException exception) { if (!string.IsNullOrWhiteSpace(exception.Message)) diff --git a/src/core/Deal.Infrastructure/Integrations/RpcExceptionExtensions.cs b/src/core/Deal.Infrastructure/Integrations/RpcExceptionExtensions.cs new file mode 100644 index 0000000..5d7da6b --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/RpcExceptionExtensions.cs @@ -0,0 +1,17 @@ +using Grpc.Core; + +namespace Deal.Infrastructure.Integrations; + +/// +/// Расширения классификации gRPC-исключений клиентов автономных сервисов. +/// +internal static class RpcExceptionExtensions +{ + /// + /// Ошибки коммуникации, при которых сервис считается недоступным (всё, кроме прикладных статусов). + /// + /// Исключение RPC. + /// True — транспорта/контракта health нет (down); false — прикладной статус (не наша зона). + public static bool IsCommunicationFailure(this RpcException exception) => + exception.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded or StatusCode.Unimplemented; +} diff --git a/src/core/Deal.Infrastructure/Integrations/ServiceHealthProbe.cs b/src/core/Deal.Infrastructure/Integrations/ServiceHealthProbe.cs index d89d958..b387ab2 100644 --- a/src/core/Deal.Infrastructure/Integrations/ServiceHealthProbe.cs +++ b/src/core/Deal.Infrastructure/Integrations/ServiceHealthProbe.cs @@ -58,7 +58,7 @@ public sealed class ServiceHealthProbe Reachable: true, Serving: response.Status == HealthCheckResponse.Types.ServingStatus.Serving); } - catch (RpcException exception) when (IsCommunicationFailure(exception)) + catch (RpcException exception) when (exception.IsCommunicationFailure()) { return ServiceHealthResult.Unreachable; } @@ -73,10 +73,4 @@ public sealed class ServiceHealthProbe return ServiceHealthResult.Unreachable; } } - - // Ошибки коммуникации, при которых сервис считается недоступным (всё, кроме прикладных статусов). - // exception: Исключение RPC. - // Возвращает: True — транспорта/контракта health нет (down), false — прикладной статус (не наша зона). - private static bool IsCommunicationFailure(RpcException exception) => - exception.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded or StatusCode.Unimplemented; } diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/FileStorageRegistrar.cs b/src/core/Deal.Infrastructure/Integrations/Storage/FileStorageRegistrar.cs index 38c0dea..a28e4d8 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/FileStorageRegistrar.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/FileStorageRegistrar.cs @@ -64,7 +64,7 @@ public static class FileStorageRegistrar // MinIO-режим: только когда секция/алиасы заполнены (Ruling 4: «заглушка-адаптер, если MinIO // недоступен» — dev/curl/unit по умолчанию работают на LocalFileStorage без MinIO). - if (MinioConfigured(options.Minio)) + if (options.Minio.IsConfigured()) { services.AddSingleton(serviceProvider => new MinioFileStorage(options.Minio, serviceProvider.GetRequiredService>())); @@ -113,20 +113,12 @@ public static class FileStorageRegistrar if (secureRaw is not null) { - options.Minio.Secure = IsTrue(secureRaw); + options.Minio.Secure = secureRaw.IsTrue(); } return options; } - // True — секция Minio заполнена настолько, что возможен Minio-адаптер (Ruling 4: Endpoint + креды). - private static bool MinioConfigured(MinioStorageOptions minio) - { - return !string.IsNullOrWhiteSpace(minio.Endpoint) - && !string.IsNullOrWhiteSpace(minio.AccessKey) - && !string.IsNullOrWhiteSpace(minio.SecretKey); - } - // Резолвит корень локального хранилища: дефолт data/attachments под ContentRoot; относительный Root — под ContentRoot; абсолютный — как есть. private static string ResolveLocalRoot(LocalStorageOptions local, string contentRootPath) { @@ -172,8 +164,4 @@ public static class FileStorageRegistrar }; } - private static bool IsTrue(string raw) - { - return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1"; - } } diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/MinioStorageOptionsExtensions.cs b/src/core/Deal.Infrastructure/Integrations/Storage/MinioStorageOptionsExtensions.cs new file mode 100644 index 0000000..bc6702f --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Storage/MinioStorageOptionsExtensions.cs @@ -0,0 +1,19 @@ +namespace Deal.Infrastructure.Integrations.Storage; + +/// +/// Расширения (выбор MinIO-адаптера по заполненности секции). +/// +internal static class MinioStorageOptionsExtensions +{ + /// + /// True — секция Minio заполнена настолько, что возможен Minio-адаптер (Ruling 4: Endpoint + креды). + /// + /// Настройки MinIO из секции Storage:Minio. + /// True — заданы Endpoint, AccessKey и SecretKey. + public static bool IsConfigured(this MinioStorageOptions minio) + { + return !string.IsNullOrWhiteSpace(minio.Endpoint) + && !string.IsNullOrWhiteSpace(minio.AccessKey) + && !string.IsNullOrWhiteSpace(minio.SecretKey); + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/StringExtensions.cs b/src/core/Deal.Infrastructure/Integrations/Storage/StringExtensions.cs new file mode 100644 index 0000000..e5ab3cd --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Storage/StringExtensions.cs @@ -0,0 +1,17 @@ +namespace Deal.Infrastructure.Integrations.Storage; + +/// +/// Расширения для разбора конфигурационных значений. +/// +internal static class StringExtensions +{ + /// + /// Разбирает строковое значение как булев флаг конфигурации: «true» (без учёта регистра) или «1». + /// + /// Сырое значение настройки. + /// True — значение распознано как включённое. + public static bool IsTrue(this string raw) + { + return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1"; + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/UriExtensions.cs b/src/core/Deal.Infrastructure/Integrations/UriExtensions.cs new file mode 100644 index 0000000..ff32ece --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/UriExtensions.cs @@ -0,0 +1,55 @@ +using System.Net; +using System.Net.Sockets; + +namespace Deal.Infrastructure.Integrations; + +/// +/// Расширения для SSRF-гейта интеграций (проверка приватности адреса). +/// +internal static class UriExtensions +{ + /// + /// Проверяет, указывает ли URL на приватный/loopback/link-local адрес (SSRF-гейт). + /// Распознаются IP-литералы (IPv4/IPv6) и имя localhost; DNS-имена считаются публичными + /// (полный egress-контроль с резолвом выполняется на сетевом периметре). + /// + /// Абсолютный http(s)-адрес. + /// True — адрес приватный/локальный (HTTP к нему запрещён). + public static bool IsPrivateEndpoint(this Uri uri) + { + string host = uri.Host; + if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!IPAddress.TryParse(host, out IPAddress? address)) + { + return false; // DNS-имя — резолв вне этого слоя + } + + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + if (IPAddress.IsLoopback(address)) + { + return true; + } + + if (address.AddressFamily == AddressFamily.InterNetwork) + { + byte[] bytes = address.GetAddressBytes(); + return bytes[0] == 10 + || (bytes[0] == 172 && bytes[1] is >= 16 and <= 31) + || (bytes[0] == 192 && bytes[1] == 168) + || bytes[0] == 169 && bytes[1] == 254 // link-local (включая 169.254.169.254 metadata) + || bytes[0] == 0; + } + + // IPv6: уникальные локальные (fc00::/7) и link-local (fe80::/10). + byte[] v6 = address.GetAddressBytes(); + return (v6[0] & 0xFE) == 0xFC || (v6[0] == 0xFE && (v6[1] & 0xC0) == 0x80); + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryTaskPatchExtensions.cs b/src/core/Deal.Modules.Discovery/Application/DiscoveryTaskPatchExtensions.cs new file mode 100644 index 0000000..429adad --- /dev/null +++ b/src/core/Deal.Modules.Discovery/Application/DiscoveryTaskPatchExtensions.cs @@ -0,0 +1,18 @@ +using Deal.Modules.Discovery.Application.Models; + +namespace Deal.Modules.Discovery.Application; + +internal static class DiscoveryTaskPatchExtensions +{ + /// + /// Содержит ли патч хотя бы одно изменяемое поле (python L289 «if not cols»). + /// + /// Нормализованный патч. + /// True — есть поле к записи. + public static bool HasChanges(this DiscoveryTaskPatch patch) + { + return patch.Name is not null || patch.Description is not null || patch.Keywords is not null + || patch.MinSubscribers is not null || patch.Lang is not null || patch.Threshold is not null + || patch.SampleSize is not null || patch.PlanJoins is not null || patch.AutoJoin is not null; + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryTasksService.cs b/src/core/Deal.Modules.Discovery/Application/DiscoveryTasksService.cs index f2d2c91..5aa2ce4 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryTasksService.cs +++ b/src/core/Deal.Modules.Discovery/Application/DiscoveryTasksService.cs @@ -139,7 +139,7 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu } } - if (!HasChanges(normalized)) + if (!normalized.HasChanges()) { // python L289–290: пустой патч — возврат текущей задачи без записи (updated_at не бампается). return current; @@ -268,16 +268,6 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu }; } - // Проверка: патч содержит хотя бы одно изменяемое поле (python L289 «if not cols»). - // patch: Нормализованный патч. - // Возвращает: True — есть поле к записи. - private static bool HasChanges(DiscoveryTaskPatch patch) - { - return patch.Name is not null || patch.Description is not null || patch.Keywords is not null - || patch.MinSubscribers is not null || patch.Lang is not null || patch.Threshold is not null - || patch.SampleSize is not null || patch.PlanJoins is not null || patch.AutoJoin is not null; - } - // Очищает список ключей: Trim + без пустых (python L226–227). // keywords: Сырые ключи (null — пусто). // Возвращает: Список непустых ключей. diff --git a/src/core/Deal.Modules.Kanban/Application/BudgetNormalizer.cs b/src/core/Deal.Modules.Kanban/Application/BudgetNormalizer.cs index 1776a14..38f4335 100644 --- a/src/core/Deal.Modules.Kanban/Application/BudgetNormalizer.cs +++ b/src/core/Deal.Modules.Kanban/Application/BudgetNormalizer.cs @@ -148,13 +148,13 @@ public static class BudgetNormalizer return direct; } - string letters = new string(s.Where(IsCurrencyLetter).ToArray()); + string letters = new string(s.Where(c => c.IsCurrencyLetter()).ToArray()); if (CurrencyAliases.TryGetValue(letters, out string? fromLetters)) { return fromLetters; } - if (s.Length == 3 && s.All(IsCurrencyLetter)) + if (s.Length == 3 && s.All(c => c.IsCurrencyLetter())) { return s; } @@ -162,14 +162,6 @@ public static class BudgetNormalizer return null; } - // Буква кода валюты: латиница A–Z или кириллица А–Я (regex прототипа [^A-ZА-Я], ai.py L286). - // c: Символ (строка уже в верхнем регистре). - // Возвращает: True — буква, участвующая в распознавании валюты. - private static bool IsCurrencyLetter(char c) - { - return c is >= 'A' and <= 'Z' or >= 'А' and <= 'Я'; - } - // Конвертация суммы через курсы к рублю; null rates → null (курсов нет — граница не конвертируется). // amount: Сумма. // fromCurrency: Исходная валюта (код). diff --git a/src/core/Deal.Modules.Kanban/Application/CharExtensions.cs b/src/core/Deal.Modules.Kanban/Application/CharExtensions.cs new file mode 100644 index 0000000..67c581c --- /dev/null +++ b/src/core/Deal.Modules.Kanban/Application/CharExtensions.cs @@ -0,0 +1,15 @@ +namespace Deal.Modules.Kanban.Application; + +/// +/// Расширения символов для нормализации бюджетной валюты. +/// +internal static class CharExtensions +{ + /// + /// Буква кода валюты: латиница A–Z или кириллица А–Я (regex прототипа [^A-ZА-Я], ai.py L286). + /// + /// Символ (строка уже в верхнем регистре). + /// True — буква, участвующая в распознавании валюты. + public static bool IsCurrencyLetter(this char c) => + c is >= 'A' and <= 'Z' or >= 'А' and <= 'Я'; +} diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetRangeDtoExtensions.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetRangeDtoExtensions.cs new file mode 100644 index 0000000..bc564c6 --- /dev/null +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetRangeDtoExtensions.cs @@ -0,0 +1,21 @@ +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Modules.Kanban.Application.ColumnRules; + +/// +/// Расширения бюджетной группы правил колонки. +/// +internal static class BudgetRangeDtoExtensions +{ + /// + /// Активна ли бюджетная группа: объект есть и не «пустой» (прототип bool(budget) — {} выключен, + /// {cur} без границ включён; см. ColumnMatcher.HasActiveRules). + /// + /// Поле budget/prices правил (может быть null). + /// True — группа бюджета участвует в матчинге. + public static bool HasBudget(this BudgetRangeDto? budget) + { + return budget is not null + && (budget.From is not null || budget.To is not null || !string.IsNullOrWhiteSpace(budget.Cur)); + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnMatcher.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnMatcher.cs index 4bc81f0..f54bb76 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnMatcher.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnMatcher.cs @@ -42,8 +42,8 @@ public static class ColumnMatcher IReadOnlyList locations = NormalizeTerms(rules.Locations); IReadOnlyList types = NormalizeTerms(rules.Types); IReadOnlyList typeTerms = TypeAliases.ExpandTerms(types); - bool budgetEnabled = HasBudget(rules.Budget); - bool pricesEnabled = HasBudget(rules.Prices); + bool budgetEnabled = rules.Budget.HasBudget(); + bool pricesEnabled = rules.Prices.HasBudget(); bool anyEnabled = keywords.Count > 0 || stack.Count > 0 || direction.Count > 0 || grade.Count > 0 || levels.Count > 0 || locations.Count > 0 || types.Count > 0 || budgetEnabled || pricesEnabled; @@ -157,38 +157,17 @@ public static class ColumnMatcher return false; } - return HasAnyTerm(rules.Direction) - || HasAnyTerm(rules.Keywords) - || HasAnyTerm(rules.Stack) - || HasAnyTerm(rules.Grade) - || HasAnyTerm(rules.Levels) - || HasAnyTerm(rules.Locations) - || HasAnyTerm(rules.Types) + return rules.Direction.HasAnyTerm() + || rules.Keywords.HasAnyTerm() + || rules.Stack.HasAnyTerm() + || rules.Grade.HasAnyTerm() + || rules.Levels.HasAnyTerm() + || rules.Locations.HasAnyTerm() + || rules.Types.HasAnyTerm() || (rules.Budget is not null && (rules.Budget.From is not null || rules.Budget.To is not null)) || (rules.Prices is not null && (rules.Prices.From is not null || rules.Prices.To is not null)); } - // Есть ли в списке непустой (после trim) терм. - // terms: Список термов группы. - // Возвращает: True — хотя бы один терм непустой. - private static bool HasAnyTerm(IReadOnlyList? terms) - { - if (terms is null) - { - return false; - } - - foreach (string? raw in terms) - { - if (!string.IsNullOrWhiteSpace(raw)) - { - return true; - } - } - - return false; - } - // Совпал ли хотя бы один терм (подстрока в тексте). // lower: Текст в нижнем регистре (очищен от ссылок). // terms: Термы в нижнем регистре. @@ -252,14 +231,4 @@ public static class ColumnMatcher return result; } - - // Активна ли бюджетная группа: объект есть и не «пустой» (прототип bool(budget) — {} выключен, - // {cur} без границ включён, но границ не задаёт — группа совпадает всегда). - // budget: Поле budget правил. - // Возвращает: True — группа бюджета участвует в матчинге. - private static bool HasBudget(BudgetRangeDto? budget) - { - return budget is not null - && (budget.From is not null || budget.To is not null || !string.IsNullOrWhiteSpace(budget.Cur)); - } } diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/TermListExtensions.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/TermListExtensions.cs new file mode 100644 index 0000000..908b580 --- /dev/null +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/TermListExtensions.cs @@ -0,0 +1,30 @@ +namespace Deal.Modules.Kanban.Application.ColumnRules; + +/// +/// Расширения списков термов правил колонки. +/// +internal static class TermListExtensions +{ + /// + /// Есть ли в списке непустой (после trim) терм. + /// + /// Список термов группы (может быть null). + /// True — хотя бы один терм непустой. + public static bool HasAnyTerm(this IReadOnlyList? terms) + { + if (terms is null) + { + return false; + } + + foreach (string? raw in terms) + { + if (!string.IsNullOrWhiteSpace(raw)) + { + return true; + } + } + + return false; + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/CodePointExtensions.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/CodePointExtensions.cs new file mode 100644 index 0000000..b9fc474 --- /dev/null +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/CodePointExtensions.cs @@ -0,0 +1,18 @@ +namespace Deal.Modules.Pipeline.Application.Parse; + +/// +/// Расширения кодовых точек для чистки текста сообщений. +/// +internal static class CodePointExtensions +{ + /// + /// Входит ли кодовая точка в эмодзи-диапазоны прототипа (pipeline.py _EMOJI_RE L137–145). + /// + /// Кодовая точка (BMP или доп. плоскость). + /// True — декоративный символ, подлежащий удалению. + public static bool IsEmojiCodePoint(this int codePoint) => + codePoint is >= 0x1F000 and <= 0x1FAFF + or >= 0x2600 and <= 0x27BF + or >= 0x2B00 and <= 0x2BFF + or 0xFE0F; +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/MessageTextCleaner.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/MessageTextCleaner.cs index 2fe7153..055374c 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Parse/MessageTextCleaner.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/MessageTextCleaner.cs @@ -208,7 +208,7 @@ public static class MessageTextCleaner continue; } - if (IsEmojiCodePoint(codePoint)) + if (codePoint.IsEmojiCodePoint()) { index += length - 1; continue; @@ -245,17 +245,6 @@ public static class MessageTextCleaner return current; } - // Входит ли кодовая точка в эмодзи-диапазоны прототипа (L137–145). - // codePoint: Кодовая точка (BMP или доп. плоскость). - // Возвращает: True — декоративный символ, подлежащий удалению. - private static bool IsEmojiCodePoint(int codePoint) - { - return codePoint is >= 0x1F000 and <= 0x1FAFF - or >= 0x2600 and <= 0x27BF - or >= 0x2B00 and <= 0x2BFF - or 0xFE0F; - } - // Обрезка по границе последнего переноса/пробела в первых limit кодовых точках // (прототип L184–192): выбирается перенос (или пробел), если он после середины лимита; иначе режем жёстко. // value: Текст длиннее лимита. diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/StringExtensions.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/StringExtensions.cs new file mode 100644 index 0000000..b55db15 --- /dev/null +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/StringExtensions.cs @@ -0,0 +1,33 @@ +namespace Deal.Modules.Pipeline.Application.Parse; + +/// +/// Расширения строк для разбора текста сообщений. +/// +internal static class StringExtensions +{ + // Служебные строки/фразы футеров агрегаторов: «суть» с ними — не структура, а шум (python L288–291). + private static readonly string[] FooterHintsArray = + { + "откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите", + "больше заявок", "узнать подробнее", "написать в лс", "пишите в лс", + }; + + /// + /// Содержит ли текст служебный футер-хинт (python L267/L275: сравнение с casefold-текстом). + /// + /// Текст (в любом регистре; null трактуется как пустая строка). + /// True — текст похож на футер агрегатора/служебную строку. + public static bool ContainsFooterHint(this string text) + { + string lower = text.ToLowerInvariant(); + foreach (string hint in FooterHintsArray) + { + if (lower.Contains(hint, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/SummaryComposer.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/SummaryComposer.cs index 86fafb5..ba3b105 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Parse/SummaryComposer.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/SummaryComposer.cs @@ -10,7 +10,7 @@ namespace Deal.Modules.Pipeline.Application.Parse; /// Карточка всегда собирается из одних и тех же блоков — Компания → Формат → О задаче → Требования → /// Будет плюсом → Условия (1:1 с cardPrompt); недостающие блоки пропускаются. Если структурированных полей нет /// ( от локального/старого разбора) — суть сохраняется как есть, но -/// отбрасывается, когда похожа на служебный футер агрегаторов (); тогда «О задаче» +/// отбрасывается, когда похожа на служебный футер агрегаторов (); тогда «О задаче» /// собирается из содержательных строк текста (, с префиксом «О задаче: »). /// public static class SummaryComposer @@ -43,13 +43,6 @@ public static class SummaryComposer "вакансия", "вакансию", "фриланс", }; - // Служебные строки/фразы футеров агрегаторов: «суть» с ними — не структура, а шум (python L288–291). - private static readonly string[] FooterHintsArray = - { - "откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите", - "больше заявок", "узнать подробнее", "написать в лс", "пишите в лс", - }; - /// /// Собирает «О заявке» из структурированных полей либо текста (python compose_summary L225–284). /// @@ -105,7 +98,7 @@ public static class SummaryComposer // Структурированных полей нет. «summary» разбора часто является копией исходника/шумом — если в нём есть // футеры/хэштеги-мусор, не используем его (python L264–268). string legacy = MessageTextCleaner.CleanShort(source.Summary); - if (legacy.Length > 0 && !ContainsFooterHint(legacy)) + if (legacy.Length > 0 && !legacy.ContainsFooterHint()) { return legacy; } @@ -121,7 +114,7 @@ public static class SummaryComposer } string lower = line.ToLowerInvariant(); - if (line.StartsWith('#') || lower.StartsWith("**#") || ContainsFooterHint(lower)) + if (line.StartsWith('#') || lower.StartsWith("**#") || lower.ContainsFooterHint()) { continue; } @@ -205,21 +198,4 @@ public static class SummaryComposer return result; } - - // Содержит ли текст служебный футер-хинт (python L267/L275: сравнение с casefold-текстом). - // text: Текст (в любом регистре). - // Возвращает: True — текст похож на футер агрегатора/служебную строку. - private static bool ContainsFooterHint(string text) - { - string lower = text.ToLowerInvariant(); - foreach (string hint in FooterHintsArray) - { - if (lower.Contains(hint, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } } diff --git a/src/core/Deal.Modules.Tenants/Application/AuditRecordDtoExtensions.cs b/src/core/Deal.Modules.Tenants/Application/AuditRecordDtoExtensions.cs new file mode 100644 index 0000000..f7a461a --- /dev/null +++ b/src/core/Deal.Modules.Tenants/Application/AuditRecordDtoExtensions.cs @@ -0,0 +1,35 @@ +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Расширения записей аудита: классификация событий входа. +/// +internal static class AuditRecordDtoExtensions +{ + // События аудита «неудачный вход» (тенант/оператор). + private static readonly string[] FailedLoginEvents = + { + AuditEvents.TenantLoginFailed, + AuditEvents.OperatorLoginFailed, + }; + + // События аудита «успешный вход» (тенант/оператор). + private static readonly string[] SuccessfulLoginEvents = + { + AuditEvents.TenantLoginOk, + AuditEvents.OperatorLoginOk, + }; + + /// + /// Неудачный вход (тенант/оператор)? + /// + /// Запись аудита. + /// True — событие из FailedLoginEvents. + public static bool IsFailedLogin(this AuditRecordDto record) => FailedLoginEvents.Contains(record.EventType); + + /// + /// Успешный вход (тенант/оператор)? + /// + /// Запись аудита. + /// True — событие из SuccessfulLoginEvents. + public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType); +} diff --git a/src/core/Deal.Modules.Tenants/Application/InviteDtoExtensions.cs b/src/core/Deal.Modules.Tenants/Application/InviteDtoExtensions.cs new file mode 100644 index 0000000..8658fe1 --- /dev/null +++ b/src/core/Deal.Modules.Tenants/Application/InviteDtoExtensions.cs @@ -0,0 +1,13 @@ +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Modules.Tenants.Application; + +internal static class InviteDtoExtensions +{ + /// + /// Истёк ли срок действия приглашения (сравнение по UTC-now). + /// + /// Приглашение. + /// True — срок действия уже прошёл. + public static bool IsExpired(this InviteDto invite) => invite.ExpiresAt <= DateTimeOffset.UtcNow; +} diff --git a/src/core/Deal.Modules.Tenants/Application/InvitesService.cs b/src/core/Deal.Modules.Tenants/Application/InvitesService.cs index c659f4b..e8ef8f3 100644 --- a/src/core/Deal.Modules.Tenants/Application/InvitesService.cs +++ b/src/core/Deal.Modules.Tenants/Application/InvitesService.cs @@ -56,7 +56,7 @@ public sealed partial class InvitesService(IInviteStore inviteStore) var active = await inviteStore.FindActiveByEmailAsync(normalizedEmail, ct); if (active is not null) { - if (IsExpired(active)) + if (active.IsExpired()) { await inviteStore.UpdateStatusAsync(active.Code, InviteStatuses.Expired, null, ct); } @@ -143,7 +143,7 @@ public sealed partial class InvitesService(IInviteStore inviteStore) public async Task GetByCodeAsync(string code, CancellationToken ct) { var invite = await inviteStore.GetByCodeAsync(code, ct); - if (invite is null || invite.Status != InviteStatuses.Pending || !IsExpired(invite)) + if (invite is null || invite.Status != InviteStatuses.Pending || !invite.IsExpired()) { return invite; } @@ -183,9 +183,4 @@ public sealed partial class InvitesService(IInviteStore inviteStore) && normalized.Length <= MaxEmailLength && EmailFormatRegex().IsMatch(normalized); } - - // Истекло ли приглашение (сравнение по UTC-now; действует только для статуса pending). - // invite: Приглашение. - // Возвращает: true, если срок действия уже прошёл. - private static bool IsExpired(InviteDto invite) => invite.ExpiresAt <= DateTimeOffset.UtcNow; } diff --git a/src/core/Deal.Modules.Tenants/Application/SuspiciousActivityService.cs b/src/core/Deal.Modules.Tenants/Application/SuspiciousActivityService.cs index a3361a3..0074435 100644 --- a/src/core/Deal.Modules.Tenants/Application/SuspiciousActivityService.cs +++ b/src/core/Deal.Modules.Tenants/Application/SuspiciousActivityService.cs @@ -87,20 +87,6 @@ public sealed class SuspiciousActivityService /// public const string SeverityMedium = "medium"; - // События аудита «неудачный вход» (тенант/оператор). - private static readonly string[] FailedLoginEvents = - { - AuditEvents.TenantLoginFailed, - AuditEvents.OperatorLoginFailed, - }; - - // События аудита «успешный вход» (тенант/оператор). - private static readonly string[] SuccessfulLoginEvents = - { - AuditEvents.TenantLoginOk, - AuditEvents.OperatorLoginOk, - }; - private readonly IAuditLogStore _store; private readonly Func _clock; @@ -181,7 +167,7 @@ public sealed class SuspiciousActivityService // findings: Накопитель находок. private static void AddFailedLoginsPerIp(IReadOnlyList records, List findings) { - Dictionary counts = CountBy(records, IsFailedLogin, record => record.Ip); + Dictionary counts = CountBy(records, record => record.IsFailedLogin(), record => record.Ip); foreach ((string ip, int count) in counts) { if (count >= FailedLoginsPerIpThreshold) @@ -201,7 +187,7 @@ public sealed class SuspiciousActivityService // findings: Накопитель находок. private static void AddFailedLoginsPerLogin(IReadOnlyList records, List findings) { - Dictionary counts = CountBy(records, IsFailedLogin, ExtractLogin); + Dictionary counts = CountBy(records, record => record.IsFailedLogin(), ExtractLogin); foreach ((string login, int count) in counts) { if (count >= FailedLoginsPerLoginThreshold) @@ -224,7 +210,7 @@ public sealed class SuspiciousActivityService var ipsByActor = new Dictionary>(); foreach (AuditRecordDto record in records) { - if (!IsSuccessfulLogin(record) || record.ActorId is not { } actorId || string.IsNullOrWhiteSpace(record.Ip)) + if (!record.IsSuccessfulLogin() || record.ActorId is not { } actorId || string.IsNullOrWhiteSpace(record.Ip)) { continue; } @@ -260,7 +246,7 @@ public sealed class SuspiciousActivityService var counts = new Dictionary(); foreach (AuditRecordDto record in records) { - if (!IsFailedLogin(record) || record.TenantId is not { } tenantId) + if (!record.IsFailedLogin() || record.TenantId is not { } tenantId) { continue; } @@ -312,16 +298,6 @@ public sealed class SuspiciousActivityService return counts; } - // Неудачный вход (тенант/оператор)? - // record: Запись аудита. - // Возвращает: True — событие из FailedLoginEvents. - private static bool IsFailedLogin(AuditRecordDto record) => FailedLoginEvents.Contains(record.EventType); - - // Успешный вход (тенант/оператор)? - // record: Запись аудита. - // Возвращает: True — событие из SuccessfulLoginEvents. - private static bool IsSuccessfulLogin(AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType); - // Извлекает логин из DetailJson записи (поле login); сбой разбора — null. // record: Запись аудита. // Возвращает: Логин либо null (деталей нет/не строка/повреждённый JSON). diff --git a/src/ml-service/Deal.Ml/Model/LabelExtensions.cs b/src/ml-service/Deal.Ml/Model/LabelExtensions.cs new file mode 100644 index 0000000..04a0383 --- /dev/null +++ b/src/ml-service/Deal.Ml/Model/LabelExtensions.cs @@ -0,0 +1,17 @@ +namespace Deal.Ml.Model; + +/// +/// Расширения для меток классов ML-модели. +/// +internal static class LabelExtensions +{ + /// + /// Метка внутреннего типа заявки (префикс t:). + /// + /// Метка класса. + /// True — метка является внутренним типом заявки (префикс t:). + public static bool IsTypeLabel(this string label) + { + return label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal); + } +} diff --git a/src/ml-service/Deal.Ml/Model/OnlineNaiveBayes.cs b/src/ml-service/Deal.Ml/Model/OnlineNaiveBayes.cs index 7bb2f9d..744f62d 100644 --- a/src/ml-service/Deal.Ml/Model/OnlineNaiveBayes.cs +++ b/src/ml-service/Deal.Ml/Model/OnlineNaiveBayes.cs @@ -115,7 +115,7 @@ public static class OnlineNaiveBayes MlTypeDecision? typeDecision = DecideType(state, scores, prior, margin); // ── колонка/спам: без t:* классов ──────────────────────────────────────────────── - List regularLabels = state.Classes.Keys.Where(label => !IsTypeLabel(label)).ToList(); + List regularLabels = state.Classes.Keys.Where(label => !label.IsTypeLabel()).ToList(); if (regularLabels.Count == 0 || scores.Count == 0) { return TakeFalse(state, typeDecision); @@ -288,11 +288,6 @@ public static class OnlineNaiveBayes private static Dictionary? TryGetTerms(ModelState state, string label) => state.TermsByLabel.TryGetValue(label, out Dictionary? terms) ? terms : null; - // Метка внутреннего типа заявки (префикс t:). - // label: Метка класса. - private static bool IsTypeLabel(string label) - => label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal); - // Фиксированный ответ «не уверен» (нет опыта/терминов — модель не решает). // ready: Готовность модели на момент вызова. private static MlPredictResult NotReady(bool ready) diff --git a/src/telegram-service/Deal.Telegram/Telegram/ExceptionExtensions.cs b/src/telegram-service/Deal.Telegram/Telegram/ExceptionExtensions.cs new file mode 100644 index 0000000..3f7fba9 --- /dev/null +++ b/src/telegram-service/Deal.Telegram/Telegram/ExceptionExtensions.cs @@ -0,0 +1,21 @@ +using System.IO; +using System.Net.Http; +using Grpc.Core; + +namespace Deal.Telegram.Telegram; + +/// +/// Расширения для классификации сбоев telegram-service. +/// +internal static class ExceptionExtensions +{ + /// + /// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE (безопасный повтор). + /// + /// Исключение для классификации. + /// True — исключение транспортного/сетевого характера. + public static bool IsTransportFailure(this Exception exception) + { + return exception is HttpRequestException or IOException or TimeoutException or RpcException; + } +} diff --git a/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs b/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs index 5ef0274..540c480 100644 --- a/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs +++ b/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs @@ -522,15 +522,10 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase // UNAVAILABLE (замечание code-review): внутренние дефекты должны быть видны как Internal. // exception: Необработанное исключение операции. private static SessionException ToSessionFailure(Exception exception) - => IsTransportFailure(exception) + => exception.IsTransportFailure() ? new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception) : new SessionException(StatusCode.Internal, SessionErrorMessages.InternalError, exception); - // Истинно транспортные/сетевые причины — только они дают UNAVAILABLE (безопасный повтор). - // exception: Исключение для классификации. - private static bool IsTransportFailure(Exception exception) - => exception is HttpRequestException or IOException or TimeoutException or RpcException; - // Фаза AuthPhase → строка канона контракта (idle|phone|code|password|qr|ready). // phase: Фаза сессии. private static string PhaseToString(AuthPhase phase)