Вынести условия-предикаты в extension-методы

HasUser из 13 endpoint-файлов сведён в AuthHelpers.HasUser;
15 приватных предикатов заменены extension-методами с удалением
дублирующих приватных методов: IsCommunicationFailure,
IsTransportFailure, IsPrivateEndpoint, IsConfigured, IsTrue,
IsExpired, IsFailedLogin/IsSuccessfulLogin, HasChanges, HasBudget,
HasAnyTerm, IsCurrencyLetter, IsEmojiCodePoint, ContainsFooterHint,
IsTypeLabel.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:08:47 +03:00
parent 9e07568ddd
commit 413eaac48c
40 changed files with 1091 additions and 993 deletions
@@ -61,7 +61,7 @@ public static class AiSuggestEndpoints
// «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3). // «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3).
private static async Task<IResult> SuggestColumnsAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> SuggestColumnsAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -84,7 +84,7 @@ public static class AiSuggestEndpoints
// Ответ — результат порта 1:1: {ok:true, keywords:[…]} (≤60) либо {ok:false, reason} (HTTP 200). // Ответ — результат порта 1:1: {ok:true, keywords:[…]} (≤60) либо {ok:false, reason} (HTTP 200).
private static async Task<IResult> SuggestKeywordsAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> SuggestKeywordsAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -93,8 +93,4 @@ public static class AiSuggestEndpoints
SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(ct); SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(ct);
return Results.Ok(result); return Results.Ok(result);
} }
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
} }
@@ -110,7 +110,7 @@ public static class CardDetailsEndpoints
// POST /api/cards: ручное создание «локальной» карточки. Ответ — созданная карточка. // POST /api/cards: ручное создание «локальной» карточки. Ответ — созданная карточка.
private static async Task<IResult> CreateCardAsync(CreateCardRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> CreateCardAsync(CreateCardRequest body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -126,7 +126,7 @@ public static class CardDetailsEndpoints
// POST /api/cards/take {cardId}: «взять в работу» — перенос карточки в planned. Ответ — карточка. // POST /api/cards/take {cardId}: «взять в работу» — перенос карточки в planned. Ответ — карточка.
private static async Task<IResult> TakeCardAsync(TakeCardRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> TakeCardAsync(TakeCardRequest body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -141,7 +141,7 @@ public static class CardDetailsEndpoints
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено». // POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
private static async Task<IResult> ClearRejectedAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> ClearRejectedAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -156,7 +156,7 @@ public static class CardDetailsEndpoints
// (budget:null, stack:null) не теряется типизированным биндингом. Ответ — обновлённая карточка. // (budget:null, stack:null) не теряется типизированным биндингом. Ответ — обновлённая карточка.
private static async Task<IResult> PatchCardAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> PatchCardAsync(string cardId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -177,7 +177,7 @@ public static class CardDetailsEndpoints
// POST /api/cards/{cardId}/links {name?,url}: добавить ссылку. Ответ — карточка. // POST /api/cards/{cardId}/links {name?,url}: добавить ссылку. Ответ — карточка.
private static async Task<IResult> AddLinkAsync(string cardId, CardLinkRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> AddLinkAsync(string cardId, CardLinkRequest body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -201,7 +201,7 @@ public static class CardDetailsEndpoints
// DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка. // DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка.
private static async Task<IResult> RemoveLinkAsync(string cardId, string linkId, HttpContext context, CancellationToken ct) private static async Task<IResult> RemoveLinkAsync(string cardId, string linkId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -218,7 +218,7 @@ public static class CardDetailsEndpoints
// имя/ContentType/поток/длина → CardsService.AddFileAsync. Ранний null — гонка (404). // имя/ContentType/поток/длина → CardsService.AddFileAsync. Ранний null — гонка (404).
private static async Task<IResult> UploadFilesAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> UploadFilesAsync(string cardId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -258,7 +258,7 @@ public static class CardDetailsEndpoints
// с Content-Length/Content-Type из дескриптора; Content-Disposition attachment, имя без кавычек. // с Content-Length/Content-Type из дескриптора; Content-Disposition attachment, имя без кавычек.
private static async Task<IResult> DownloadFileAsync(string cardId, string fileId, HttpContext context, CancellationToken ct) private static async Task<IResult> DownloadFileAsync(string cardId, string fileId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -307,7 +307,7 @@ public static class CardDetailsEndpoints
// DELETE /api/cards/{cardId}/files/{fileId}: открепить файл. Ответ — карточка. // DELETE /api/cards/{cardId}/files/{fileId}: открепить файл. Ответ — карточка.
private static async Task<IResult> RemoveFileAsync(string cardId, string fileId, HttpContext context, CancellationToken ct) private static async Task<IResult> RemoveFileAsync(string cardId, string fileId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -322,7 +322,7 @@ public static class CardDetailsEndpoints
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка. // POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
private static async Task<IResult> SetReminderAsync(string cardId, ReminderSetRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> SetReminderAsync(string cardId, ReminderSetRequest body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -347,7 +347,7 @@ public static class CardDetailsEndpoints
// DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка. // DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка.
private static async Task<IResult> ClearReminderAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> ClearReminderAsync(string cardId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -361,7 +361,7 @@ public static class CardDetailsEndpoints
// POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка. // POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка.
private static async Task<IResult> SnoozeReminderAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> SnoozeReminderAsync(string cardId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -424,9 +424,4 @@ public static class CardDetailsEndpoints
return null; return null;
} }
} }
// Разрешена ли сессия запроса.
// context: Контекст запроса.
// Возвращает: True — сессия есть.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
} }
+14 -18
View File
@@ -89,7 +89,7 @@ public static class CardsEndpoints
// Параметр col принят как алиас containerId (совместимость со старым фронтом). // Параметр col принят как алиас containerId (совместимость со старым фронтом).
private static async Task<IResult> ListCardsAsync(string? containerId, string? col, HttpContext context, CancellationToken ct) private static async Task<IResult> ListCardsAsync(string? containerId, string? col, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -108,7 +108,7 @@ public static class CardsEndpoints
// GET /api/cards/counts: плоская wire-форма счётчиков {new, &lt;col&gt;:{count,new}, learning, ml, ai} (L161163, §4.1 L257). // GET /api/cards/counts: плоская wire-форма счётчиков {new, &lt;col&gt;:{count,new}, learning, ml, ai} (L161163, §4.1 L257).
private static async Task<IResult> CountsAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> CountsAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -132,7 +132,7 @@ public static class CardsEndpoints
// GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168). // GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168).
private static async Task<IResult> GetCardAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> GetCardAsync(string cardId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -147,7 +147,7 @@ public static class CardsEndpoints
// POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}. // POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}.
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -160,7 +160,7 @@ public static class CardsEndpoints
// POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {ok:true}. // POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {ok:true}.
private static async Task<IResult> MarkColSeenAsync(MarkColBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> MarkColSeenAsync(MarkColBody body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -184,7 +184,7 @@ public static class CardsEndpoints
// несуществующем контейнере, 404 — карточки нет. // несуществующем контейнере, 404 — карточки нет.
private static async Task<IResult> MoveAsync(string cardId, MoveBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> MoveAsync(string cardId, MoveBody body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -214,7 +214,7 @@ public static class CardsEndpoints
// POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203207); ответ {ok:true}; 404. // POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203207); ответ {ok:true}; 404.
private static async Task<IResult> TrashAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> TrashAsync(string cardId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -234,7 +234,7 @@ public static class CardsEndpoints
// POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404. // POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404.
private static async Task<IResult> RestoreAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> RestoreAsync(string cardId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -254,7 +254,7 @@ public static class CardsEndpoints
// DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217221); ответ {ok:true}; 404. // DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217221); ответ {ok:true}; 404.
private static async Task<IResult> DeleteAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> DeleteAsync(string cardId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -274,7 +274,7 @@ public static class CardsEndpoints
// POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400. // POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400.
private static async Task<IResult> ClearColAsync(ClearColBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> ClearColAsync(ClearColBody body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -289,7 +289,7 @@ public static class CardsEndpoints
// POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404. // POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404.
private static async Task<IResult> AddCommentAsync(string cardId, CommentBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> AddCommentAsync(string cardId, CommentBody body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -321,7 +321,7 @@ public static class CardsEndpoints
// ct: Токен отмены. // ct: Токен отмены.
private static async Task<IResult> ReclassifyAsync(ReclassifyBody? body, HttpContext context, CancellationToken ct) private static async Task<IResult> ReclassifyAsync(ReclassifyBody? body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -339,7 +339,7 @@ public static class CardsEndpoints
// ct: Токен отмены. // ct: Токен отмены.
private static async Task<IResult> ReclassifyOneAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> ReclassifyOneAsync(string cardId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); 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 L254256). Ответ {leads, messages: []}. // GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254256). Ответ {leads, messages: []}.
private static async Task<IResult> SearchAsync(string? q, HttpContext context, CancellationToken ct) private static async Task<IResult> SearchAsync(string? q, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -440,8 +440,4 @@ public static class CardsEndpoints
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>(); ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
return await containers.GetAsync(col, ct) is not null; return await containers.GetAsync(col, ct) is not null;
} }
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
} }
@@ -62,7 +62,7 @@ public static class ContainersEndpoints
// GET /api/containers?space=: список контейнеров пространства (или всех) со счётчиками. // GET /api/containers?space=: список контейнеров пространства (или всех) со счётчиками.
private static async Task<IResult> ListContainersAsync(string? space, HttpContext context, CancellationToken ct) private static async Task<IResult> ListContainersAsync(string? space, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -74,7 +74,7 @@ public static class ContainersEndpoints
// POST /api/containers: создать контейнер; ответ {id}. // POST /api/containers: создать контейнер; ответ {id}.
private static async Task<IResult> CreateContainerAsync(ContainerCreateRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> CreateContainerAsync(ContainerCreateRequest body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -105,7 +105,7 @@ public static class ContainersEndpoints
// PATCH /api/containers/{id}: частичное обновление; ответ {id}; 404 «Контейнер не найден». // PATCH /api/containers/{id}: частичное обновление; ответ {id}; 404 «Контейнер не найден».
private static async Task<IResult> PatchContainerAsync(string containerId, JsonElement body, HttpContext context, CancellationToken ct) private static async Task<IResult> PatchContainerAsync(string containerId, JsonElement body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -165,7 +165,7 @@ public static class ContainersEndpoints
// POST /api/containers/{id}/accept: принять ИИ-предложение (suggested=false). // POST /api/containers/{id}/accept: принять ИИ-предложение (suggested=false).
private static async Task<IResult> AcceptSuggestedAsync(string containerId, HttpContext context, CancellationToken ct) private static async Task<IResult> AcceptSuggestedAsync(string containerId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -185,7 +185,7 @@ public static class ContainersEndpoints
// DELETE /api/containers/{id}: удалить контейнер; карточки → «Неразобранное» новыми. // DELETE /api/containers/{id}: удалить контейнер; карточки → «Неразобранное» новыми.
private static async Task<IResult> DeleteContainerAsync(string containerId, HttpContext context, CancellationToken ct) private static async Task<IResult> DeleteContainerAsync(string containerId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -201,7 +201,7 @@ public static class ContainersEndpoints
// POST /api/containers/reorder: порядок контейнеров пространства; ответ {ok:true}. // POST /api/containers/reorder: порядок контейнеров пространства; ответ {ok:true}.
private static async Task<IResult> ReorderContainersAsync(OrderBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> ReorderContainersAsync(OrderBody body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -219,7 +219,7 @@ public static class ContainersEndpoints
// GET /api/containers/state: свёрнутость/ширина всех колонок (colState). // GET /api/containers/state: свёрнутость/ширина всех колонок (colState).
private static async Task<IResult> GetColumnsStateAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> GetColumnsStateAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -239,7 +239,7 @@ public static class ContainersEndpoints
// PATCH /api/containers/{id}/state: merge патча в состояние колонки; ответ — состояние этой колонки. // PATCH /api/containers/{id}/state: merge патча в состояние колонки; ответ — состояние этой колонки.
private static async Task<IResult> PatchColumnStateAsync(string containerId, ColStateBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> PatchColumnStateAsync(string containerId, ColStateBody body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -296,9 +296,4 @@ public static class ContainersEndpoints
return wire; return wire;
} }
// Разрешена ли сессия запроса.
// context: Контекст запроса.
// Возвращает: True — сессия есть.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
} }
@@ -127,7 +127,7 @@ public static class DiscoveryEndpoints
// GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141143). // GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141143).
private static async Task<IResult> ListTasksAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> ListTasksAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -140,7 +140,7 @@ public static class DiscoveryEndpoints
// POST /api/discovery/tasks: создать задачу поиска (create_task L146151; дефолты — в сервисе). // POST /api/discovery/tasks: создать задачу поиска (create_task L146151; дефолты — в сервисе).
private static async Task<IResult> CreateTaskAsync(DiscoveryTaskCreateBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> CreateTaskAsync(DiscoveryTaskCreateBody body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -160,7 +160,7 @@ public static class DiscoveryEndpoints
// PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154161; 404/400). // PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154161; 404/400).
private static async Task<IResult> PatchTaskAsync(string task_id, DiscoveryTaskPatchBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> PatchTaskAsync(string task_id, DiscoveryTaskPatchBody body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -180,7 +180,7 @@ public static class DiscoveryEndpoints
// DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164168). // DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164168).
private static async Task<IResult> DeleteTaskAsync(string task_id, HttpContext context, CancellationToken ct) private static async Task<IResult> DeleteTaskAsync(string task_id, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -193,7 +193,7 @@ public static class DiscoveryEndpoints
// POST /api/discovery/tasks/{task_id}/start: запуск поиска (start_task L171179; пустые ключи → 400). // POST /api/discovery/tasks/{task_id}/start: запуск поиска (start_task L171179; пустые ключи → 400).
private static async Task<IResult> StartTaskAsync(string task_id, HttpContext context, CancellationToken ct) private static async Task<IResult> StartTaskAsync(string task_id, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -213,7 +213,7 @@ public static class DiscoveryEndpoints
// POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181187). // POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181187).
private static async Task<IResult> PauseTaskAsync(string task_id, HttpContext context, CancellationToken ct) private static async Task<IResult> PauseTaskAsync(string task_id, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -230,7 +230,7 @@ public static class DiscoveryEndpoints
// Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины. // Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины.
private static async Task<IResult> GenerateKeywordsAsync(string task_id, HttpContext context, CancellationToken ct) private static async Task<IResult> GenerateKeywordsAsync(string task_id, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -277,7 +277,7 @@ public static class DiscoveryEndpoints
// new|review|joined|rejected (list_candidates L216224; невалидный статус — пустой список). // new|review|joined|rejected (list_candidates L216224; невалидный статус — пустой список).
private static async Task<IResult> ListCandidatesAsync(string task_id, string? status, HttpContext context, CancellationToken ct) private static async Task<IResult> ListCandidatesAsync(string task_id, string? status, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -299,7 +299,7 @@ public static class DiscoveryEndpoints
// mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины. // mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины.
private static async Task<IResult> JoinCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct) private static async Task<IResult> JoinCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -355,7 +355,7 @@ public static class DiscoveryEndpoints
// (reject_candidate L253264; уже вступившего — нельзя, 400). // (reject_candidate L253264; уже вступившего — нельзя, 400).
private static async Task<IResult> RejectCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct) private static async Task<IResult> RejectCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -386,7 +386,7 @@ public static class DiscoveryEndpoints
// GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269271). // GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269271).
private static async Task<IResult> ListBlacklistAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> ListBlacklistAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -399,7 +399,7 @@ public static class DiscoveryEndpoints
// DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274277). // DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274277).
private static async Task<IResult> RemoveBlacklistAsync(string dialog_id, HttpContext context, CancellationToken ct) private static async Task<IResult> RemoveBlacklistAsync(string dialog_id, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -412,7 +412,7 @@ public static class DiscoveryEndpoints
// GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282285), события от новых к старым. // GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282285), события от новых к старым.
private static async Task<IResult> TaskLogAsync(string task_id, HttpContext context, CancellationToken ct) private static async Task<IResult> TaskLogAsync(string task_id, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -533,8 +533,4 @@ public static class DiscoveryEndpoints
AutoJoin = body.AutoJoin, AutoJoin = body.AutoJoin,
}; };
} }
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
} }
@@ -45,7 +45,7 @@ public static class FilterTesterEndpoints
// POST /api/admin/check-message: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267284). // POST /api/admin/check-message: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267284).
private static async Task<IResult> CheckAsync(CheckMessageRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> CheckAsync(CheckMessageRequest body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -72,8 +72,4 @@ public static class FilterTesterEndpoints
passed = true, passed = true,
}); });
} }
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
} }
+5 -9
View File
@@ -78,7 +78,7 @@ public static class MlEndpoints
// GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L6675). // GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L6675).
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -90,7 +90,7 @@ public static class MlEndpoints
// POST /api/ml/reset: полный сброс модели + очистка очереди обучения (ml_routes.py L7881). // POST /api/ml/reset: полный сброс модели + очистка очереди обучения (ml_routes.py L7881).
private static async Task<IResult> ResetAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> ResetAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -102,7 +102,7 @@ public static class MlEndpoints
// POST /api/ml/predict: проверка ML на тексте (ml_routes.py L8490). // POST /api/ml/predict: проверка ML на тексте (ml_routes.py L8490).
private static async Task<IResult> PredictAsync(MlPredictRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> PredictAsync(MlPredictRequest body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -137,7 +137,7 @@ public static class MlEndpoints
// POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112134). // POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112134).
private static async Task<IResult> CandidatesAsync(MlCandidatesRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> CandidatesAsync(MlCandidatesRequest body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -150,7 +150,7 @@ public static class MlEndpoints
// POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137171). // POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137171).
private static async Task<IResult> ApplyAsync(MlApplyRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> ApplyAsync(MlApplyRequest body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -175,8 +175,4 @@ public static class MlEndpoints
leadId = result.LeadId, leadId = result.LeadId,
}); });
} }
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
} }
@@ -77,7 +77,7 @@ public static class PipelineEndpoints
// GET /api/pipeline/stats: сводка вкладки {queue:{new,ai,total}, rejected} (processing_routes.py L1720, stats L315320). // GET /api/pipeline/stats: сводка вкладки {queue:{new,ai,total}, rejected} (processing_routes.py L1720, stats L315320).
private static async Task<IResult> StatsAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> StatsAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -91,7 +91,7 @@ public static class PipelineEndpoints
// rejected_count L201202. limit — дефолт 100, clamp 1..500 делает сервис (ListQueueAsync). // rejected_count L201202. limit — дефолт 100, clamp 1..500 делает сервис (ListQueueAsync).
private static async Task<IResult> QueueAsync(int? limit, HttpContext context, CancellationToken ct) private static async Task<IResult> QueueAsync(int? limit, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -108,7 +108,7 @@ public static class PipelineEndpoints
// первыми; offset ≥ 0, limit 1..500 (clamp в сервисе), значения эхом в ответе {items,total,offset,limit}. // первыми; offset ≥ 0, limit 1..500 (clamp в сервисе), значения эхом в ответе {items,total,offset,limit}.
private static async Task<IResult> RejectedAsync(string? q, int? offset, int? limit, HttpContext context, CancellationToken ct) private static async Task<IResult> RejectedAsync(string? q, int? offset, int? limit, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -121,7 +121,7 @@ public static class PipelineEndpoints
// POST /api/pipeline/rejected/clear: полная безвозвратная очистка отсева (processing_routes.py L4549, clear_all L120125). // POST /api/pipeline/rejected/clear: полная безвозвратная очистка отсева (processing_routes.py L4549, clear_all L120125).
private static async Task<IResult> ClearAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> ClearAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -135,7 +135,7 @@ public static class PipelineEndpoints
// Прототип не проверяет наличие записи — 404 не шлём (план Task 9 L444; Ruling 10 «always ok»). // Прототип не проверяет наличие записи — 404 не шлём (план Task 9 L444; Ruling 10 «always ok»).
private static async Task<IResult> DeleteAsync(string rejId, HttpContext context, CancellationToken ct) private static async Task<IResult> DeleteAsync(string rejId, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -151,7 +151,7 @@ public static class PipelineEndpoints
// прототипом); записи нет — 404 «Запись не найдена» (текст 404 — слой эндпоинтов). // прототипом); записи нет — 404 «Запись не найдена» (текст 404 — слой эндпоинтов).
private static async Task<IResult> ReturnAsync(string rejId, ReturnReasonRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> ReturnAsync(string rejId, ReturnReasonRequest body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -167,8 +167,4 @@ public static class PipelineEndpoints
? EndpointResults.BadRequest(result.Error) ? EndpointResults.BadRequest(result.Error)
: Results.Ok(new { id = result.Id, returned = result.Returned, returnedAt = result.ReturnedAtMs }); : 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;
} }
@@ -47,7 +47,7 @@ public static class RatesEndpoints
// GET /api/rates: текущий кэш курсов тенанта (+ ленивый фоновый refresh при необходимости). // GET /api/rates: текущий кэш курсов тенанта (+ ленивый фоновый refresh при необходимости).
private static async Task<IResult> GetRatesAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> GetRatesAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -70,7 +70,7 @@ public static class RatesEndpoints
// POST /api/rates/refresh: принудительное обновление; ответ {ok, rates} (1:1 settings_routes.py L229232). // POST /api/rates/refresh: принудительное обновление; ответ {ok, rates} (1:1 settings_routes.py L229232).
private static async Task<IResult> RefreshRatesAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> RefreshRatesAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -82,8 +82,4 @@ public static class RatesEndpoints
RatesDto current = await ratesService.GetAsync(ct); RatesDto current = await ratesService.GetAsync(ct);
return Results.Ok(new { ok, rates = current }); return Results.Ok(new { ok, rates = current });
} }
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
} }
@@ -53,7 +53,7 @@ public static class SettingsEndpoints
// GET /api/settings: публичный снимок настроек текущего тенанта. // GET /api/settings: публичный снимок настроек текущего тенанта.
private static async Task<IResult> GetSettingsAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> GetSettingsAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -65,7 +65,7 @@ public static class SettingsEndpoints
// PATCH /api/settings: частичное обновление настроек; ответ — полный снимок после применения. // PATCH /api/settings: частичное обновление настроек; ответ — полный снимок после применения.
private static async Task<IResult> PatchSettingsAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> PatchSettingsAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -125,8 +125,4 @@ public static class SettingsEndpoints
_ => true, _ => true,
}; };
} }
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
} }
@@ -58,7 +58,7 @@ public static class StorageEndpoints
// PumpOnceAsync (сбой не роняет тик) → SSE new_card по созданным карточкам → queue. Формы — 1:1 с прототипом. // PumpOnceAsync (сбой не роняет тик) → SSE new_card по созданным карточкам → queue. Формы — 1:1 с прототипом.
private static async Task<IResult> AdminTickAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> AdminTickAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -75,7 +75,7 @@ public static class StorageEndpoints
// is_ready() → ready) — кнопка Settings фронта показывает ошибку по ready (store.js L18831889). // is_ready() → ready) — кнопка Settings фронта показывает ошибку по ready (store.js L18831889).
private static async Task<IResult> FtsRebuildAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> FtsRebuildAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -84,8 +84,4 @@ public static class StorageEndpoints
bool ok = await fts.RebuildAsync(ct); bool ok = await fts.RebuildAsync(ct);
return Results.Ok(new { ok, ready = ok }); return Results.Ok(new { ok, ready = ok });
} }
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
} }
@@ -124,7 +124,7 @@ public static class TelegramEndpoints
// GET /api/tg/status: статус аккаунта/фазы входа (tg_routes.py L6365; форма §4.9). // GET /api/tg/status: статус аккаунта/фазы входа (tg_routes.py L6365; форма §4.9).
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -136,7 +136,7 @@ public static class TelegramEndpoints
// POST /api/tg/start-phone: запросить код по номеру (tg_routes.py L6874; python L134147). // POST /api/tg/start-phone: запросить код по номеру (tg_routes.py L6874; python L134147).
private static async Task<IResult> StartPhoneAsync(TgStartPhoneRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> StartPhoneAsync(TgStartPhoneRequest body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -168,7 +168,7 @@ public static class TelegramEndpoints
// POST /api/tg/start-qr: начать QR-вход (tg_routes.py L7783; python qr_start L286300). // POST /api/tg/start-qr: начать QR-вход (tg_routes.py L7783; python qr_start L286300).
private static async Task<IResult> StartQrAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> StartQrAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -205,7 +205,7 @@ public static class TelegramEndpoints
// POST /api/tg/send-code: отправить SMS-код (tg_routes.py L8694; python submit_code L149166). // POST /api/tg/send-code: отправить SMS-код (tg_routes.py L8694; python submit_code L149166).
private static async Task<IResult> SendCodeAsync(TgSendCodeRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> SendCodeAsync(TgSendCodeRequest body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -231,7 +231,7 @@ public static class TelegramEndpoints
// POST /api/tg/send-password: облачный пароль 2FA (tg_routes.py L97103; python submit_password L168176). // POST /api/tg/send-password: облачный пароль 2FA (tg_routes.py L97103; python submit_password L168176).
private static async Task<IResult> SendPasswordAsync(TgSendPasswordRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> SendPasswordAsync(TgSendPasswordRequest body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -257,7 +257,7 @@ public static class TelegramEndpoints
// POST /api/tg/logout: отключить аккаунт, удалить сессию (tg_routes.py L106109; python disconnect L189207). // POST /api/tg/logout: отключить аккаунт, удалить сессию (tg_routes.py L106109; python disconnect L189207).
private static async Task<IResult> LogoutAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> LogoutAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -277,7 +277,7 @@ public static class TelegramEndpoints
// GET /api/tg/dialogs: список диалогов из БД (tg_routes.py L112114; list_dialogs L521534). // GET /api/tg/dialogs: список диалогов из БД (tg_routes.py L112114; list_dialogs L521534).
private static async Task<IResult> DialogsAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> DialogsAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -310,7 +310,7 @@ public static class TelegramEndpoints
// POST /api/tg/dialogs/refresh: синхронизировать каталог диалогов из Telegram (tg_routes.py L117122). // POST /api/tg/dialogs/refresh: синхронизировать каталог диалогов из Telegram (tg_routes.py L117122).
private static async Task<IResult> DialogsRefreshAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> DialogsRefreshAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -351,7 +351,7 @@ public static class TelegramEndpoints
// POST /api/tg/dialogs/monitor-all: мониторинг всех каналов (tg_routes.py L125129; L548567). // POST /api/tg/dialogs/monitor-all: мониторинг всех каналов (tg_routes.py L125129; L548567).
private static async Task<IResult> MonitorAllAsync(TgMonitorBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> MonitorAllAsync(TgMonitorBody body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -383,7 +383,7 @@ public static class TelegramEndpoints
// POST /api/tg/dialogs/backfill-all: «Перечитать» включённые каналы в фоне (tg_routes.py L132136). // POST /api/tg/dialogs/backfill-all: «Перечитать» включённые каналы в фоне (tg_routes.py L132136).
private static async Task<IResult> BackfillAllAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> BackfillAllAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -402,7 +402,7 @@ public static class TelegramEndpoints
// POST /api/tg/dialogs/{dialog_id}/monitor: вкл/выкл мониторинг канала (tg_routes.py L139142; L536546). // POST /api/tg/dialogs/{dialog_id}/monitor: вкл/выкл мониторинг канала (tg_routes.py L139142; L536546).
private static async Task<IResult> SetMonitorAsync(string dialog_id, TgMonitorBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> SetMonitorAsync(string dialog_id, TgMonitorBody body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -435,7 +435,7 @@ public static class TelegramEndpoints
// Сервер-only эндпоинт (фронт не вызывает, api-map §3.3 L139/п.9): первый разбор/догон одного канала. // Сервер-only эндпоинт (фронт не вызывает, api-map §3.3 L139/п.9): первый разбор/догон одного канала.
private static async Task<IResult> BackfillDialogAsync(string dialog_id, HttpContext context, CancellationToken ct) private static async Task<IResult> BackfillDialogAsync(string dialog_id, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -455,7 +455,7 @@ public static class TelegramEndpoints
// POST /api/tg/dialogs/preview: последние сообщения диалога (tg_routes.py L151153; dialog_messages L583620). // POST /api/tg/dialogs/preview: последние сообщения диалога (tg_routes.py L151153; dialog_messages L583620).
private static async Task<IResult> PreviewAsync(TgPreviewBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> PreviewAsync(TgPreviewBody body, HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -519,10 +519,6 @@ public static class TelegramEndpoints
}; };
} }
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
// Безопасный разбор сохранённого api_id: невалидное значение — не исключение, а false // Безопасный разбор сохранённого api_id: невалидное значение — не исключение, а false
// (400 на HTTP-слое; Security review: int.Parse по пользовательской настройке давал FormatException). // (400 на HTTP-слое; Security review: int.Parse по пользовательской настройке давал FormatException).
// apiId: Строка api_id из настроек (цифры 6..9, см. SettingsService). // apiId: Строка api_id из настроек (цифры 6..9, см. SettingsService).
@@ -51,7 +51,7 @@ public static class TelegramQrImageEndpoint
// GET /api/tg/qr-image: SVG QR-кода фазы входа «qr» (tg_routes.py L3039). // GET /api/tg/qr-image: SVG QR-кода фазы входа «qr» (tg_routes.py L3039).
private static async Task<IResult> QrImageAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> QrImageAsync(HttpContext context, CancellationToken ct)
{ {
if (!HasUser(context)) if (!context.HasUser())
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
@@ -81,8 +81,4 @@ public static class TelegramQrImageEndpoint
context.Response.Headers.ContentDisposition = "inline"; context.Response.Headers.ContentDisposition = "inline";
return Results.Text(svg, SvgMediaType); return Results.Text(svg, SvgMediaType);
} }
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
} }
+7
View File
@@ -41,6 +41,13 @@ public static class AuthHelpers
public static CurrentUser? GetCurrentUser(this HttpContext context) => public static CurrentUser? GetCurrentUser(this HttpContext context) =>
context.Items[CurrentUserItemKey] as CurrentUser; context.Items[CurrentUserItemKey] as CurrentUser;
/// <summary>
/// Проверяет, разрешена ли для запроса пользовательская сессия.
/// </summary>
/// <param name="context">Контекст запроса.</param>
/// <returns>True — текущий пользователь установлен.</returns>
public static bool HasUser(this HttpContext context) => context.GetCurrentUser() is not null;
/// <summary> /// <summary>
/// Кладёт оператора в <c>HttpContext.Items</c>. /// Кладёт оператора в <c>HttpContext.Items</c>.
/// </summary> /// </summary>
@@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using Deal.Modules.Settings.Application; using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
@@ -128,7 +127,7 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
// Private/loopback/link-local литералы и localhost запрещены для не-local провайдеров (локальные // Private/loopback/link-local литералы и localhost запрещены для не-local провайдеров (локальные
// провайдеры — ветка IsLocal выше, HTTP для них не выполняется вовсе). DNS-имена не резолвятся // провайдеры — ветка IsLocal выше, HTTP для них не выполняется вовсе). DNS-имена не резолвятся
// здесь (полный egress-контроль с резолвом — на уровне сетевого периметра/прокси). // здесь (полный egress-контроль с резолвом — на уровне сетевого периметра/прокси).
if (IsPrivateEndpoint(modelsUri)) if (modelsUri.IsPrivateEndpoint())
{ {
return BuildResult(request, name, ok: false, message: PrivateEndpointNotAllowedMessage); return BuildResult(request, name, ok: false, message: PrivateEndpointNotAllowedMessage);
} }
@@ -247,50 +246,8 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
} }
// Проверяет, указывает ли URL на приватный/loopback/link-local адрес (SSRF-гейт). // Исключение HTTP-слоя.
// Распознаются 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);
}
/// <param name="exception">Исключение HTTP-слоя.</param>
/// <returns>Человекочитаемый текст причины.</returns>
private static string ConnectionErrorDetail(HttpRequestException exception) private static string ConnectionErrorDetail(HttpRequestException exception)
{ {
if (!string.IsNullOrWhiteSpace(exception.Message)) if (!string.IsNullOrWhiteSpace(exception.Message))
@@ -0,0 +1,17 @@
using Grpc.Core;
namespace Deal.Infrastructure.Integrations;
/// <summary>
/// Расширения классификации gRPC-исключений клиентов автономных сервисов.
/// </summary>
internal static class RpcExceptionExtensions
{
/// <summary>
/// Ошибки коммуникации, при которых сервис считается недоступным (всё, кроме прикладных статусов).
/// </summary>
/// <param name="exception">Исключение RPC.</param>
/// <returns>True — транспорта/контракта health нет (down); false — прикладной статус (не наша зона).</returns>
public static bool IsCommunicationFailure(this RpcException exception) =>
exception.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded or StatusCode.Unimplemented;
}
@@ -58,7 +58,7 @@ public sealed class ServiceHealthProbe
Reachable: true, Reachable: true,
Serving: response.Status == HealthCheckResponse.Types.ServingStatus.Serving); Serving: response.Status == HealthCheckResponse.Types.ServingStatus.Serving);
} }
catch (RpcException exception) when (IsCommunicationFailure(exception)) catch (RpcException exception) when (exception.IsCommunicationFailure())
{ {
return ServiceHealthResult.Unreachable; return ServiceHealthResult.Unreachable;
} }
@@ -73,10 +73,4 @@ public sealed class ServiceHealthProbe
return ServiceHealthResult.Unreachable; 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;
} }
@@ -64,7 +64,7 @@ public static class FileStorageRegistrar
// MinIO-режим: только когда секция/алиасы заполнены (Ruling 4: «заглушка-адаптер, если MinIO // MinIO-режим: только когда секция/алиасы заполнены (Ruling 4: «заглушка-адаптер, если MinIO
// недоступен» — dev/curl/unit по умолчанию работают на LocalFileStorage без MinIO). // недоступен» — dev/curl/unit по умолчанию работают на LocalFileStorage без MinIO).
if (MinioConfigured(options.Minio)) if (options.Minio.IsConfigured())
{ {
services.AddSingleton<IFileStorage>(serviceProvider => services.AddSingleton<IFileStorage>(serviceProvider =>
new MinioFileStorage(options.Minio, serviceProvider.GetRequiredService<ILogger<MinioFileStorage>>())); new MinioFileStorage(options.Minio, serviceProvider.GetRequiredService<ILogger<MinioFileStorage>>()));
@@ -113,20 +113,12 @@ public static class FileStorageRegistrar
if (secureRaw is not null) if (secureRaw is not null)
{ {
options.Minio.Secure = IsTrue(secureRaw); options.Minio.Secure = secureRaw.IsTrue();
} }
return options; 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; абсолютный — как есть. // Резолвит корень локального хранилища: дефолт data/attachments под ContentRoot; относительный Root — под ContentRoot; абсолютный — как есть.
private static string ResolveLocalRoot(LocalStorageOptions local, string contentRootPath) 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";
}
} }
@@ -0,0 +1,19 @@
namespace Deal.Infrastructure.Integrations.Storage;
/// <summary>
/// Расширения <see cref="MinioStorageOptions"/> (выбор MinIO-адаптера по заполненности секции).
/// </summary>
internal static class MinioStorageOptionsExtensions
{
/// <summary>
/// True — секция Minio заполнена настолько, что возможен Minio-адаптер (Ruling 4: Endpoint + креды).
/// </summary>
/// <param name="minio">Настройки MinIO из секции Storage:Minio.</param>
/// <returns>True — заданы Endpoint, AccessKey и SecretKey.</returns>
public static bool IsConfigured(this MinioStorageOptions minio)
{
return !string.IsNullOrWhiteSpace(minio.Endpoint)
&& !string.IsNullOrWhiteSpace(minio.AccessKey)
&& !string.IsNullOrWhiteSpace(minio.SecretKey);
}
}
@@ -0,0 +1,17 @@
namespace Deal.Infrastructure.Integrations.Storage;
/// <summary>
/// Расширения <see cref="string"/> для разбора конфигурационных значений.
/// </summary>
internal static class StringExtensions
{
/// <summary>
/// Разбирает строковое значение как булев флаг конфигурации: «true» (без учёта регистра) или «1».
/// </summary>
/// <param name="raw">Сырое значение настройки.</param>
/// <returns>True — значение распознано как включённое.</returns>
public static bool IsTrue(this string raw)
{
return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1";
}
}
@@ -0,0 +1,55 @@
using System.Net;
using System.Net.Sockets;
namespace Deal.Infrastructure.Integrations;
/// <summary>
/// Расширения <see cref="Uri"/> для SSRF-гейта интеграций (проверка приватности адреса).
/// </summary>
internal static class UriExtensions
{
/// <summary>
/// Проверяет, указывает ли URL на приватный/loopback/link-local адрес (SSRF-гейт).
/// Распознаются IP-литералы (IPv4/IPv6) и имя localhost; DNS-имена считаются публичными
/// (полный egress-контроль с резолвом выполняется на сетевом периметре).
/// </summary>
/// <param name="uri">Абсолютный http(s)-адрес.</param>
/// <returns>True — адрес приватный/локальный (HTTP к нему запрещён).</returns>
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);
}
}
@@ -0,0 +1,18 @@
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Modules.Discovery.Application;
internal static class DiscoveryTaskPatchExtensions
{
/// <summary>
/// Содержит ли патч хотя бы одно изменяемое поле (python L289 «if not cols»).
/// </summary>
/// <param name="patch">Нормализованный патч.</param>
/// <returns>True — есть поле к записи.</returns>
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;
}
}
@@ -139,7 +139,7 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
} }
} }
if (!HasChanges(normalized)) if (!normalized.HasChanges())
{ {
// python L289290: пустой патч — возврат текущей задачи без записи (updated_at не бампается). // python L289290: пустой патч — возврат текущей задачи без записи (updated_at не бампается).
return current; 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 L226227). // Очищает список ключей: Trim + без пустых (python L226227).
// keywords: Сырые ключи (null — пусто). // keywords: Сырые ключи (null — пусто).
// Возвращает: Список непустых ключей. // Возвращает: Список непустых ключей.
@@ -148,13 +148,13 @@ public static class BudgetNormalizer
return direct; 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)) if (CurrencyAliases.TryGetValue(letters, out string? fromLetters))
{ {
return fromLetters; return fromLetters;
} }
if (s.Length == 3 && s.All(IsCurrencyLetter)) if (s.Length == 3 && s.All(c => c.IsCurrencyLetter()))
{ {
return s; return s;
} }
@@ -162,14 +162,6 @@ public static class BudgetNormalizer
return null; 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 (курсов нет — граница не конвертируется). // Конвертация суммы через курсы к рублю; null rates → null (курсов нет — граница не конвертируется).
// amount: Сумма. // amount: Сумма.
// fromCurrency: Исходная валюта (код). // fromCurrency: Исходная валюта (код).
@@ -0,0 +1,15 @@
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Расширения символов для нормализации бюджетной валюты.
/// </summary>
internal static class CharExtensions
{
/// <summary>
/// Буква кода валюты: латиница A–Z или кириллица А–Я (regex прототипа [^A-ZА-Я], ai.py L286).
/// </summary>
/// <param name="c">Символ (строка уже в верхнем регистре).</param>
/// <returns>True — буква, участвующая в распознавании валюты.</returns>
public static bool IsCurrencyLetter(this char c) =>
c is >= 'A' and <= 'Z' or >= 'А' and <= 'Я';
}
@@ -0,0 +1,21 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Расширения бюджетной группы правил колонки.
/// </summary>
internal static class BudgetRangeDtoExtensions
{
/// <summary>
/// Активна ли бюджетная группа: объект есть и не «пустой» (прототип bool(budget) — {} выключен,
/// {cur} без границ включён; см. ColumnMatcher.HasActiveRules).
/// </summary>
/// <param name="budget">Поле budget/prices правил (может быть null).</param>
/// <returns>True — группа бюджета участвует в матчинге.</returns>
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));
}
}
@@ -42,8 +42,8 @@ public static class ColumnMatcher
IReadOnlyList<string> locations = NormalizeTerms(rules.Locations); IReadOnlyList<string> locations = NormalizeTerms(rules.Locations);
IReadOnlyList<string> types = NormalizeTerms(rules.Types); IReadOnlyList<string> types = NormalizeTerms(rules.Types);
IReadOnlyList<string> typeTerms = TypeAliases.ExpandTerms(types); IReadOnlyList<string> typeTerms = TypeAliases.ExpandTerms(types);
bool budgetEnabled = HasBudget(rules.Budget); bool budgetEnabled = rules.Budget.HasBudget();
bool pricesEnabled = HasBudget(rules.Prices); bool pricesEnabled = rules.Prices.HasBudget();
bool anyEnabled = keywords.Count > 0 || stack.Count > 0 || direction.Count > 0 || grade.Count > 0 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; || levels.Count > 0 || locations.Count > 0 || types.Count > 0 || budgetEnabled || pricesEnabled;
@@ -157,38 +157,17 @@ public static class ColumnMatcher
return false; return false;
} }
return HasAnyTerm(rules.Direction) return rules.Direction.HasAnyTerm()
|| HasAnyTerm(rules.Keywords) || rules.Keywords.HasAnyTerm()
|| HasAnyTerm(rules.Stack) || rules.Stack.HasAnyTerm()
|| HasAnyTerm(rules.Grade) || rules.Grade.HasAnyTerm()
|| HasAnyTerm(rules.Levels) || rules.Levels.HasAnyTerm()
|| HasAnyTerm(rules.Locations) || rules.Locations.HasAnyTerm()
|| HasAnyTerm(rules.Types) || rules.Types.HasAnyTerm()
|| (rules.Budget is not null && (rules.Budget.From is not null || rules.Budget.To is not null)) || (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)); || (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<string>? terms)
{
if (terms is null)
{
return false;
}
foreach (string? raw in terms)
{
if (!string.IsNullOrWhiteSpace(raw))
{
return true;
}
}
return false;
}
// Совпал ли хотя бы один терм (подстрока в тексте). // Совпал ли хотя бы один терм (подстрока в тексте).
// lower: Текст в нижнем регистре (очищен от ссылок). // lower: Текст в нижнем регистре (очищен от ссылок).
// terms: Термы в нижнем регистре. // terms: Термы в нижнем регистре.
@@ -252,14 +231,4 @@ public static class ColumnMatcher
return result; 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));
}
} }
@@ -0,0 +1,30 @@
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Расширения списков термов правил колонки.
/// </summary>
internal static class TermListExtensions
{
/// <summary>
/// Есть ли в списке непустой (после trim) терм.
/// </summary>
/// <param name="terms">Список термов группы (может быть null).</param>
/// <returns>True — хотя бы один терм непустой.</returns>
public static bool HasAnyTerm(this IReadOnlyList<string>? terms)
{
if (terms is null)
{
return false;
}
foreach (string? raw in terms)
{
if (!string.IsNullOrWhiteSpace(raw))
{
return true;
}
}
return false;
}
}
@@ -0,0 +1,18 @@
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Расширения кодовых точек для чистки текста сообщений.
/// </summary>
internal static class CodePointExtensions
{
/// <summary>
/// Входит ли кодовая точка в эмодзи-диапазоны прототипа (pipeline.py _EMOJI_RE L137145).
/// </summary>
/// <param name="codePoint">Кодовая точка (BMP или доп. плоскость).</param>
/// <returns>True — декоративный символ, подлежащий удалению.</returns>
public static bool IsEmojiCodePoint(this int codePoint) =>
codePoint is >= 0x1F000 and <= 0x1FAFF
or >= 0x2600 and <= 0x27BF
or >= 0x2B00 and <= 0x2BFF
or 0xFE0F;
}
@@ -208,7 +208,7 @@ public static class MessageTextCleaner
continue; continue;
} }
if (IsEmojiCodePoint(codePoint)) if (codePoint.IsEmojiCodePoint())
{ {
index += length - 1; index += length - 1;
continue; continue;
@@ -245,17 +245,6 @@ public static class MessageTextCleaner
return current; 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 кодовых точках // Обрезка по границе последнего переноса/пробела в первых limit кодовых точках
// (прототип L184–192): выбирается перенос (или пробел), если он после середины лимита; иначе режем жёстко. // (прототип L184–192): выбирается перенос (или пробел), если он после середины лимита; иначе режем жёстко.
// value: Текст длиннее лимита. // value: Текст длиннее лимита.
@@ -0,0 +1,33 @@
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Расширения строк для разбора текста сообщений.
/// </summary>
internal static class StringExtensions
{
// Служебные строки/фразы футеров агрегаторов: «суть» с ними — не структура, а шум (python L288291).
private static readonly string[] FooterHintsArray =
{
"откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите",
"больше заявок", "узнать подробнее", "написать в лс", "пишите в лс",
};
/// <summary>
/// Содержит ли текст служебный футер-хинт (python L267/L275: сравнение с casefold-текстом).
/// </summary>
/// <param name="text">Текст (в любом регистре; null трактуется как пустая строка).</param>
/// <returns>True — текст похож на футер агрегатора/служебную строку.</returns>
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;
}
}
@@ -10,7 +10,7 @@ namespace Deal.Modules.Pipeline.Application.Parse;
/// Карточка всегда собирается из одних и тех же блоков — Компания → Формат → О задаче → Требования → /// Карточка всегда собирается из одних и тех же блоков — Компания → Формат → О задаче → Требования →
/// Будет плюсом → Условия (1:1 с cardPrompt); недостающие блоки пропускаются. Если структурированных полей нет /// Будет плюсом → Условия (1:1 с cardPrompt); недостающие блоки пропускаются. Если структурированных полей нет
/// (<see cref="ParsedCardContent.Summary"/> от локального/старого разбора) — суть сохраняется как есть, но /// (<see cref="ParsedCardContent.Summary"/> от локального/старого разбора) — суть сохраняется как есть, но
/// отбрасывается, когда похожа на служебный футер агрегаторов (<see cref="FooterHints"/>); тогда «О задаче» /// отбрасывается, когда похожа на служебный футер агрегаторов (<see cref="StringExtensions.ContainsFooterHint(string)"/>); тогда «О задаче»
/// собирается из содержательных строк текста (<see cref="LocalSummary"/>, с префиксом «О задаче: »). /// собирается из содержательных строк текста (<see cref="LocalSummary"/>, с префиксом «О задаче: »).
/// </remarks> /// </remarks>
public static class SummaryComposer public static class SummaryComposer
@@ -43,13 +43,6 @@ public static class SummaryComposer
"вакансия", "вакансию", "фриланс", "вакансия", "вакансию", "фриланс",
}; };
// Служебные строки/фразы футеров агрегаторов: «суть» с ними — не структура, а шум (python L288291).
private static readonly string[] FooterHintsArray =
{
"откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите",
"больше заявок", "узнать подробнее", "написать в лс", "пишите в лс",
};
/// <summary> /// <summary>
/// Собирает «О заявке» из структурированных полей либо текста (python compose_summary L225284). /// Собирает «О заявке» из структурированных полей либо текста (python compose_summary L225284).
/// </summary> /// </summary>
@@ -105,7 +98,7 @@ public static class SummaryComposer
// Структурированных полей нет. «summary» разбора часто является копией исходника/шумом — если в нём есть // Структурированных полей нет. «summary» разбора часто является копией исходника/шумом — если в нём есть
// футеры/хэштеги-мусор, не используем его (python L264268). // футеры/хэштеги-мусор, не используем его (python L264268).
string legacy = MessageTextCleaner.CleanShort(source.Summary); string legacy = MessageTextCleaner.CleanShort(source.Summary);
if (legacy.Length > 0 && !ContainsFooterHint(legacy)) if (legacy.Length > 0 && !legacy.ContainsFooterHint())
{ {
return legacy; return legacy;
} }
@@ -121,7 +114,7 @@ public static class SummaryComposer
} }
string lower = line.ToLowerInvariant(); string lower = line.ToLowerInvariant();
if (line.StartsWith('#') || lower.StartsWith("**#") || ContainsFooterHint(lower)) if (line.StartsWith('#') || lower.StartsWith("**#") || lower.ContainsFooterHint())
{ {
continue; continue;
} }
@@ -205,21 +198,4 @@ public static class SummaryComposer
return result; 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;
}
} }
@@ -0,0 +1,35 @@
namespace Deal.Modules.Tenants.Application.Models;
/// <summary>
/// Расширения записей аудита: классификация событий входа.
/// </summary>
internal static class AuditRecordDtoExtensions
{
// События аудита «неудачный вход» (тенант/оператор).
private static readonly string[] FailedLoginEvents =
{
AuditEvents.TenantLoginFailed,
AuditEvents.OperatorLoginFailed,
};
// События аудита «успешный вход» (тенант/оператор).
private static readonly string[] SuccessfulLoginEvents =
{
AuditEvents.TenantLoginOk,
AuditEvents.OperatorLoginOk,
};
/// <summary>
/// Неудачный вход (тенант/оператор)?
/// </summary>
/// <param name="record">Запись аудита.</param>
/// <returns>True — событие из FailedLoginEvents.</returns>
public static bool IsFailedLogin(this AuditRecordDto record) => FailedLoginEvents.Contains(record.EventType);
/// <summary>
/// Успешный вход (тенант/оператор)?
/// </summary>
/// <param name="record">Запись аудита.</param>
/// <returns>True — событие из SuccessfulLoginEvents.</returns>
public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType);
}
@@ -0,0 +1,13 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application;
internal static class InviteDtoExtensions
{
/// <summary>
/// Истёк ли срок действия приглашения (сравнение по UTC-now).
/// </summary>
/// <param name="invite">Приглашение.</param>
/// <returns>True — срок действия уже прошёл.</returns>
public static bool IsExpired(this InviteDto invite) => invite.ExpiresAt <= DateTimeOffset.UtcNow;
}
@@ -56,7 +56,7 @@ public sealed partial class InvitesService(IInviteStore inviteStore)
var active = await inviteStore.FindActiveByEmailAsync(normalizedEmail, ct); var active = await inviteStore.FindActiveByEmailAsync(normalizedEmail, ct);
if (active is not null) if (active is not null)
{ {
if (IsExpired(active)) if (active.IsExpired())
{ {
await inviteStore.UpdateStatusAsync(active.Code, InviteStatuses.Expired, null, ct); await inviteStore.UpdateStatusAsync(active.Code, InviteStatuses.Expired, null, ct);
} }
@@ -143,7 +143,7 @@ public sealed partial class InvitesService(IInviteStore inviteStore)
public async Task<InviteDto?> GetByCodeAsync(string code, CancellationToken ct) public async Task<InviteDto?> GetByCodeAsync(string code, CancellationToken ct)
{ {
var invite = await inviteStore.GetByCodeAsync(code, 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; return invite;
} }
@@ -183,9 +183,4 @@ public sealed partial class InvitesService(IInviteStore inviteStore)
&& normalized.Length <= MaxEmailLength && normalized.Length <= MaxEmailLength
&& EmailFormatRegex().IsMatch(normalized); && EmailFormatRegex().IsMatch(normalized);
} }
// Истекло ли приглашение (сравнение по UTC-now; действует только для статуса pending).
// invite: Приглашение.
// Возвращает: true, если срок действия уже прошёл.
private static bool IsExpired(InviteDto invite) => invite.ExpiresAt <= DateTimeOffset.UtcNow;
} }
@@ -87,20 +87,6 @@ public sealed class SuspiciousActivityService
/// </summary> /// </summary>
public const string SeverityMedium = "medium"; 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 IAuditLogStore _store;
private readonly Func<DateTimeOffset> _clock; private readonly Func<DateTimeOffset> _clock;
@@ -181,7 +167,7 @@ public sealed class SuspiciousActivityService
// findings: Накопитель находок. // findings: Накопитель находок.
private static void AddFailedLoginsPerIp(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings) private static void AddFailedLoginsPerIp(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
{ {
Dictionary<string, int> counts = CountBy(records, IsFailedLogin, record => record.Ip); Dictionary<string, int> counts = CountBy(records, record => record.IsFailedLogin(), record => record.Ip);
foreach ((string ip, int count) in counts) foreach ((string ip, int count) in counts)
{ {
if (count >= FailedLoginsPerIpThreshold) if (count >= FailedLoginsPerIpThreshold)
@@ -201,7 +187,7 @@ public sealed class SuspiciousActivityService
// findings: Накопитель находок. // findings: Накопитель находок.
private static void AddFailedLoginsPerLogin(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings) private static void AddFailedLoginsPerLogin(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
{ {
Dictionary<string, int> counts = CountBy(records, IsFailedLogin, ExtractLogin); Dictionary<string, int> counts = CountBy(records, record => record.IsFailedLogin(), ExtractLogin);
foreach ((string login, int count) in counts) foreach ((string login, int count) in counts)
{ {
if (count >= FailedLoginsPerLoginThreshold) if (count >= FailedLoginsPerLoginThreshold)
@@ -224,7 +210,7 @@ public sealed class SuspiciousActivityService
var ipsByActor = new Dictionary<Guid, HashSet<string>>(); var ipsByActor = new Dictionary<Guid, HashSet<string>>();
foreach (AuditRecordDto record in records) 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; continue;
} }
@@ -260,7 +246,7 @@ public sealed class SuspiciousActivityService
var counts = new Dictionary<Guid, int>(); var counts = new Dictionary<Guid, int>();
foreach (AuditRecordDto record in records) foreach (AuditRecordDto record in records)
{ {
if (!IsFailedLogin(record) || record.TenantId is not { } tenantId) if (!record.IsFailedLogin() || record.TenantId is not { } tenantId)
{ {
continue; continue;
} }
@@ -312,16 +298,6 @@ public sealed class SuspiciousActivityService
return counts; 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. // Извлекает логин из DetailJson записи (поле login); сбой разбора — null.
// record: Запись аудита. // record: Запись аудита.
// Возвращает: Логин либо null (деталей нет/не строка/повреждённый JSON). // Возвращает: Логин либо null (деталей нет/не строка/повреждённый JSON).
@@ -0,0 +1,17 @@
namespace Deal.Ml.Model;
/// <summary>
/// Расширения <see cref="string"/> для меток классов ML-модели.
/// </summary>
internal static class LabelExtensions
{
/// <summary>
/// Метка внутреннего типа заявки (префикс t:).
/// </summary>
/// <param name="label">Метка класса.</param>
/// <returns>True — метка является внутренним типом заявки (префикс t:).</returns>
public static bool IsTypeLabel(this string label)
{
return label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal);
}
}
@@ -115,7 +115,7 @@ public static class OnlineNaiveBayes
MlTypeDecision? typeDecision = DecideType(state, scores, prior, margin); MlTypeDecision? typeDecision = DecideType(state, scores, prior, margin);
// ── колонка/спам: без t:* классов ──────────────────────────────────────────────── // ── колонка/спам: без t:* классов ────────────────────────────────────────────────
List<string> regularLabels = state.Classes.Keys.Where(label => !IsTypeLabel(label)).ToList(); List<string> regularLabels = state.Classes.Keys.Where(label => !label.IsTypeLabel()).ToList();
if (regularLabels.Count == 0 || scores.Count == 0) if (regularLabels.Count == 0 || scores.Count == 0)
{ {
return TakeFalse(state, typeDecision); return TakeFalse(state, typeDecision);
@@ -288,11 +288,6 @@ public static class OnlineNaiveBayes
private static Dictionary<string, double>? TryGetTerms(ModelState state, string label) private static Dictionary<string, double>? TryGetTerms(ModelState state, string label)
=> state.TermsByLabel.TryGetValue(label, out Dictionary<string, double>? terms) ? terms : null; => state.TermsByLabel.TryGetValue(label, out Dictionary<string, double>? terms) ? terms : null;
// Метка внутреннего типа заявки (префикс t:).
// label: Метка класса.
private static bool IsTypeLabel(string label)
=> label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal);
// Фиксированный ответ «не уверен» (нет опыта/терминов — модель не решает). // Фиксированный ответ «не уверен» (нет опыта/терминов — модель не решает).
// ready: Готовность модели на момент вызова. // ready: Готовность модели на момент вызова.
private static MlPredictResult NotReady(bool ready) private static MlPredictResult NotReady(bool ready)
@@ -0,0 +1,21 @@
using System.IO;
using System.Net.Http;
using Grpc.Core;
namespace Deal.Telegram.Telegram;
/// <summary>
/// Расширения <see cref="Exception"/> для классификации сбоев telegram-service.
/// </summary>
internal static class ExceptionExtensions
{
/// <summary>
/// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE (безопасный повтор).
/// </summary>
/// <param name="exception">Исключение для классификации.</param>
/// <returns>True — исключение транспортного/сетевого характера.</returns>
public static bool IsTransportFailure(this Exception exception)
{
return exception is HttpRequestException or IOException or TimeoutException or RpcException;
}
}
@@ -522,15 +522,10 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
// UNAVAILABLE (замечание code-review): внутренние дефекты должны быть видны как Internal. // UNAVAILABLE (замечание code-review): внутренние дефекты должны быть видны как Internal.
// exception: Необработанное исключение операции. // exception: Необработанное исключение операции.
private static SessionException ToSessionFailure(Exception exception) private static SessionException ToSessionFailure(Exception exception)
=> IsTransportFailure(exception) => exception.IsTransportFailure()
? new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception) ? new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception)
: new SessionException(StatusCode.Internal, SessionErrorMessages.InternalError, 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). // Фаза AuthPhase → строка канона контракта (idle|phone|code|password|qr|ready).
// phase: Фаза сессии. // phase: Фаза сессии.
private static string PhaseToString(AuthPhase phase) private static string PhaseToString(AuthPhase phase)