Вынести условия-предикаты в 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:
@@ -61,7 +61,7 @@ public static class AiSuggestEndpoints
|
||||
// «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3).
|
||||
private static async Task<IResult> SuggestColumnsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -84,7 +84,7 @@ public static class AiSuggestEndpoints
|
||||
// Ответ — результат порта 1:1: {ok:true, keywords:[…]} (≤60) либо {ok:false, reason} (HTTP 200).
|
||||
private static async Task<IResult> SuggestKeywordsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -93,8 +93,4 @@ public static class AiSuggestEndpoints
|
||||
SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(ct);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ public static class CardDetailsEndpoints
|
||||
// POST /api/cards: ручное создание «локальной» карточки. Ответ — созданная карточка.
|
||||
private static async Task<IResult> CreateCardAsync(CreateCardRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -126,7 +126,7 @@ public static class CardDetailsEndpoints
|
||||
// POST /api/cards/take {cardId}: «взять в работу» — перенос карточки в planned. Ответ — карточка.
|
||||
private static async Task<IResult> TakeCardAsync(TakeCardRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -141,7 +141,7 @@ public static class CardDetailsEndpoints
|
||||
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
|
||||
private static async Task<IResult> ClearRejectedAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -156,7 +156,7 @@ public static class CardDetailsEndpoints
|
||||
// (budget:null, stack:null) не теряется типизированным биндингом. Ответ — обновлённая карточка.
|
||||
private static async Task<IResult> PatchCardAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -177,7 +177,7 @@ public static class CardDetailsEndpoints
|
||||
// POST /api/cards/{cardId}/links {name?,url}: добавить ссылку. Ответ — карточка.
|
||||
private static async Task<IResult> AddLinkAsync(string cardId, CardLinkRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -201,7 +201,7 @@ public static class CardDetailsEndpoints
|
||||
// DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка.
|
||||
private static async Task<IResult> RemoveLinkAsync(string cardId, string linkId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -218,7 +218,7 @@ public static class CardDetailsEndpoints
|
||||
// имя/ContentType/поток/длина → CardsService.AddFileAsync. Ранний null — гонка (404).
|
||||
private static async Task<IResult> UploadFilesAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -258,7 +258,7 @@ public static class CardDetailsEndpoints
|
||||
// с Content-Length/Content-Type из дескриптора; Content-Disposition attachment, имя без кавычек.
|
||||
private static async Task<IResult> DownloadFileAsync(string cardId, string fileId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -307,7 +307,7 @@ public static class CardDetailsEndpoints
|
||||
// DELETE /api/cards/{cardId}/files/{fileId}: открепить файл. Ответ — карточка.
|
||||
private static async Task<IResult> RemoveFileAsync(string cardId, string fileId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -322,7 +322,7 @@ public static class CardDetailsEndpoints
|
||||
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
|
||||
private static async Task<IResult> SetReminderAsync(string cardId, ReminderSetRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -347,7 +347,7 @@ public static class CardDetailsEndpoints
|
||||
// DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка.
|
||||
private static async Task<IResult> ClearReminderAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -361,7 +361,7 @@ public static class CardDetailsEndpoints
|
||||
// POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка.
|
||||
private static async Task<IResult> SnoozeReminderAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -424,9 +424,4 @@ public static class CardDetailsEndpoints
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса.
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: True — сессия есть.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ public static class CardsEndpoints
|
||||
// Параметр col принят как алиас containerId (совместимость со старым фронтом).
|
||||
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);
|
||||
}
|
||||
@@ -108,7 +108,7 @@ public static class CardsEndpoints
|
||||
// GET /api/cards/counts: плоская wire-форма счётчиков {new, <col>:{count,new}, learning, ml, ai} (L161–163, §4.1 L257).
|
||||
private static async Task<IResult> CountsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -132,7 +132,7 @@ public static class CardsEndpoints
|
||||
// GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168).
|
||||
private static async Task<IResult> GetCardAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -147,7 +147,7 @@ public static class CardsEndpoints
|
||||
// POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}.
|
||||
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -160,7 +160,7 @@ public static class CardsEndpoints
|
||||
// POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {ok:true}.
|
||||
private static async Task<IResult> MarkColSeenAsync(MarkColBody body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -184,7 +184,7 @@ public static class CardsEndpoints
|
||||
// несуществующем контейнере, 404 — карточки нет.
|
||||
private static async Task<IResult> MoveAsync(string cardId, MoveBody body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -214,7 +214,7 @@ public static class CardsEndpoints
|
||||
// POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203–207); ответ {ok:true}; 404.
|
||||
private static async Task<IResult> TrashAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -234,7 +234,7 @@ public static class CardsEndpoints
|
||||
// POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404.
|
||||
private static async Task<IResult> RestoreAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -254,7 +254,7 @@ public static class CardsEndpoints
|
||||
// DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217–221); ответ {ok:true}; 404.
|
||||
private static async Task<IResult> DeleteAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -274,7 +274,7 @@ public static class CardsEndpoints
|
||||
// POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400.
|
||||
private static async Task<IResult> ClearColAsync(ClearColBody body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -289,7 +289,7 @@ public static class CardsEndpoints
|
||||
// POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404.
|
||||
private static async Task<IResult> AddCommentAsync(string cardId, CommentBody body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -321,7 +321,7 @@ public static class CardsEndpoints
|
||||
// ct: Токен отмены.
|
||||
private static async Task<IResult> ReclassifyAsync(ReclassifyBody? body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -339,7 +339,7 @@ public static class CardsEndpoints
|
||||
// ct: Токен отмены.
|
||||
private static async Task<IResult> ReclassifyOneAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -416,7 +416,7 @@ public static class CardsEndpoints
|
||||
// GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254–256). Ответ {leads, messages: []}.
|
||||
private static async Task<IResult> SearchAsync(string? q, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -440,8 +440,4 @@ public static class CardsEndpoints
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
return await containers.GetAsync(col, ct) is not null;
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ public static class ContainersEndpoints
|
||||
// GET /api/containers?space=: список контейнеров пространства (или всех) со счётчиками.
|
||||
private static async Task<IResult> ListContainersAsync(string? space, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -74,7 +74,7 @@ public static class ContainersEndpoints
|
||||
// POST /api/containers: создать контейнер; ответ {id}.
|
||||
private static async Task<IResult> CreateContainerAsync(ContainerCreateRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -105,7 +105,7 @@ public static class ContainersEndpoints
|
||||
// PATCH /api/containers/{id}: частичное обновление; ответ {id}; 404 «Контейнер не найден».
|
||||
private static async Task<IResult> PatchContainerAsync(string containerId, JsonElement body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -165,7 +165,7 @@ public static class ContainersEndpoints
|
||||
// POST /api/containers/{id}/accept: принять ИИ-предложение (suggested=false).
|
||||
private static async Task<IResult> AcceptSuggestedAsync(string containerId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -185,7 +185,7 @@ public static class ContainersEndpoints
|
||||
// DELETE /api/containers/{id}: удалить контейнер; карточки → «Неразобранное» новыми.
|
||||
private static async Task<IResult> DeleteContainerAsync(string containerId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -201,7 +201,7 @@ public static class ContainersEndpoints
|
||||
// POST /api/containers/reorder: порядок контейнеров пространства; ответ {ok:true}.
|
||||
private static async Task<IResult> ReorderContainersAsync(OrderBody body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -219,7 +219,7 @@ public static class ContainersEndpoints
|
||||
// GET /api/containers/state: свёрнутость/ширина всех колонок (colState).
|
||||
private static async Task<IResult> GetColumnsStateAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -239,7 +239,7 @@ public static class ContainersEndpoints
|
||||
// PATCH /api/containers/{id}/state: merge патча в состояние колонки; ответ — состояние этой колонки.
|
||||
private static async Task<IResult> PatchColumnStateAsync(string containerId, ColStateBody body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -296,9 +296,4 @@ public static class ContainersEndpoints
|
||||
|
||||
return wire;
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса.
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: True — сессия есть.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ public static class DiscoveryEndpoints
|
||||
// GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141–143).
|
||||
private static async Task<IResult> ListTasksAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -140,7 +140,7 @@ public static class DiscoveryEndpoints
|
||||
// POST /api/discovery/tasks: создать задачу поиска (create_task L146–151; дефолты — в сервисе).
|
||||
private static async Task<IResult> CreateTaskAsync(DiscoveryTaskCreateBody body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -160,7 +160,7 @@ public static class DiscoveryEndpoints
|
||||
// PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154–161; 404/400).
|
||||
private static async Task<IResult> PatchTaskAsync(string task_id, DiscoveryTaskPatchBody body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -180,7 +180,7 @@ public static class DiscoveryEndpoints
|
||||
// DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164–168).
|
||||
private static async Task<IResult> DeleteTaskAsync(string task_id, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -193,7 +193,7 @@ public static class DiscoveryEndpoints
|
||||
// POST /api/discovery/tasks/{task_id}/start: запуск поиска (start_task L171–179; пустые ключи → 400).
|
||||
private static async Task<IResult> StartTaskAsync(string task_id, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -213,7 +213,7 @@ public static class DiscoveryEndpoints
|
||||
// POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181–187).
|
||||
private static async Task<IResult> PauseTaskAsync(string task_id, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -230,7 +230,7 @@ public static class DiscoveryEndpoints
|
||||
// Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины.
|
||||
private static async Task<IResult> GenerateKeywordsAsync(string task_id, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -277,7 +277,7 @@ public static class DiscoveryEndpoints
|
||||
// new|review|joined|rejected (list_candidates L216–224; невалидный статус — пустой список).
|
||||
private static async Task<IResult> ListCandidatesAsync(string task_id, string? status, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -299,7 +299,7 @@ public static class DiscoveryEndpoints
|
||||
// mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины.
|
||||
private static async Task<IResult> JoinCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -355,7 +355,7 @@ public static class DiscoveryEndpoints
|
||||
// (reject_candidate L253–264; уже вступившего — нельзя, 400).
|
||||
private static async Task<IResult> RejectCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -386,7 +386,7 @@ public static class DiscoveryEndpoints
|
||||
// GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269–271).
|
||||
private static async Task<IResult> ListBlacklistAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -399,7 +399,7 @@ public static class DiscoveryEndpoints
|
||||
// DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274–277).
|
||||
private static async Task<IResult> RemoveBlacklistAsync(string dialog_id, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -412,7 +412,7 @@ public static class DiscoveryEndpoints
|
||||
// GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282–285), события от новых к старым.
|
||||
private static async Task<IResult> TaskLogAsync(string task_id, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -533,8 +533,4 @@ public static class DiscoveryEndpoints
|
||||
AutoJoin = body.AutoJoin,
|
||||
};
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ public static class FilterTesterEndpoints
|
||||
// POST /api/admin/check-message: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267–284).
|
||||
private static async Task<IResult> CheckAsync(CheckMessageRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -72,8 +72,4 @@ public static class FilterTesterEndpoints
|
||||
passed = true,
|
||||
});
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ public static class MlEndpoints
|
||||
// GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L66–75).
|
||||
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -90,7 +90,7 @@ public static class MlEndpoints
|
||||
// POST /api/ml/reset: полный сброс модели + очистка очереди обучения (ml_routes.py L78–81).
|
||||
private static async Task<IResult> ResetAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -102,7 +102,7 @@ public static class MlEndpoints
|
||||
// POST /api/ml/predict: проверка ML на тексте (ml_routes.py L84–90).
|
||||
private static async Task<IResult> PredictAsync(MlPredictRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -137,7 +137,7 @@ public static class MlEndpoints
|
||||
// POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112–134).
|
||||
private static async Task<IResult> CandidatesAsync(MlCandidatesRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -150,7 +150,7 @@ public static class MlEndpoints
|
||||
// POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137–171).
|
||||
private static async Task<IResult> ApplyAsync(MlApplyRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -175,8 +175,4 @@ public static class MlEndpoints
|
||||
leadId = result.LeadId,
|
||||
});
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ public static class PipelineEndpoints
|
||||
// GET /api/pipeline/stats: сводка вкладки {queue:{new,ai,total}, rejected} (processing_routes.py L17–20, stats L315–320).
|
||||
private static async Task<IResult> StatsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -91,7 +91,7 @@ public static class PipelineEndpoints
|
||||
// rejected_count L201–202. limit — дефолт 100, clamp 1..500 делает сервис (ListQueueAsync).
|
||||
private static async Task<IResult> QueueAsync(int? limit, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -108,7 +108,7 @@ public static class PipelineEndpoints
|
||||
// первыми; offset ≥ 0, limit 1..500 (clamp в сервисе), значения эхом в ответе {items,total,offset,limit}.
|
||||
private static async Task<IResult> RejectedAsync(string? q, int? offset, int? limit, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -121,7 +121,7 @@ public static class PipelineEndpoints
|
||||
// POST /api/pipeline/rejected/clear: полная безвозвратная очистка отсева (processing_routes.py L45–49, clear_all L120–125).
|
||||
private static async Task<IResult> ClearAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -135,7 +135,7 @@ public static class PipelineEndpoints
|
||||
// Прототип не проверяет наличие записи — 404 не шлём (план Task 9 L444; Ruling 10 «always ok»).
|
||||
private static async Task<IResult> DeleteAsync(string rejId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -151,7 +151,7 @@ public static class PipelineEndpoints
|
||||
// прототипом); записи нет — 404 «Запись не найдена» (текст 404 — слой эндпоинтов).
|
||||
private static async Task<IResult> ReturnAsync(string rejId, ReturnReasonRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -167,8 +167,4 @@ public static class PipelineEndpoints
|
||||
? EndpointResults.BadRequest(result.Error)
|
||||
: Results.Ok(new { id = result.Id, returned = result.Returned, returnedAt = result.ReturnedAtMs });
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ public static class RatesEndpoints
|
||||
// GET /api/rates: текущий кэш курсов тенанта (+ ленивый фоновый refresh при необходимости).
|
||||
private static async Task<IResult> GetRatesAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public static class RatesEndpoints
|
||||
// POST /api/rates/refresh: принудительное обновление; ответ {ok, rates} (1:1 settings_routes.py L229–232).
|
||||
private static async Task<IResult> RefreshRatesAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -82,8 +82,4 @@ public static class RatesEndpoints
|
||||
RatesDto current = await ratesService.GetAsync(ct);
|
||||
return Results.Ok(new { ok, rates = current });
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
|
||||
@@ -1,132 +1,128 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Api;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3);
|
||||
/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения
|
||||
/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию:
|
||||
/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH
|
||||
/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400).
|
||||
/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое
|
||||
/// обновление кэша курсов (<see cref="RatesRefreshScheduler"/>, Ruling 6); пересчёт карточек при смене
|
||||
/// targetCurrency/conversionOn выполняет сам SettingsService через порт <see cref="IRatesChangedListener"/>
|
||||
/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12).
|
||||
/// <para>
|
||||
/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не
|
||||
/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость
|
||||
/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса
|
||||
/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401,
|
||||
/// а не 500 при резолве.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SettingsEndpoints
|
||||
{
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
private const string SettingsPath = "/settings";
|
||||
private const string SettingsOpenApiTag = "settings";
|
||||
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует GET/PATCH /api/settings.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(ApiGroupPrefix).WithTags(SettingsOpenApiTag);
|
||||
|
||||
group.MapGet(SettingsPath, GetSettingsAsync);
|
||||
group.MapPatch(SettingsPath, PatchSettingsAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/settings: публичный снимок настроек текущего тенанта.
|
||||
private static async Task<IResult> GetSettingsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
||||
return Results.Ok(await settingsService.GetPublicAsync(ct));
|
||||
}
|
||||
|
||||
// PATCH /api/settings: частичное обновление настроек; ответ — полный снимок после применения.
|
||||
private static async Task<IResult> PatchSettingsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт).
|
||||
Dictionary<string, JsonElement>? body;
|
||||
try
|
||||
{
|
||||
body = await JsonSerializer.DeserializeAsync<Dictionary<string, JsonElement>>(
|
||||
context.Request.Body,
|
||||
options: null,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Не-JSON или не-объект целиком — ошибка запроса: 400 + detail
|
||||
// (в прототипе FastAPI на такое тело — 422).
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
||||
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
|
||||
|
||||
// Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
|
||||
|
||||
// Смена источника курсов в PATCH (settings_routes.py L188–189) — фоновое обновление кэша
|
||||
// курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource.
|
||||
if (ShouldScheduleRatesRefresh(body))
|
||||
{
|
||||
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
|
||||
}
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188).
|
||||
// body: Тело PATCH — публичные ключи §4.6.
|
||||
// Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка).
|
||||
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
|
||||
{
|
||||
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет.
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()),
|
||||
JsonValueKind.Null => false,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Api;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3);
|
||||
/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения
|
||||
/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию:
|
||||
/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH
|
||||
/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400).
|
||||
/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое
|
||||
/// обновление кэша курсов (<see cref="RatesRefreshScheduler"/>, Ruling 6); пересчёт карточек при смене
|
||||
/// targetCurrency/conversionOn выполняет сам SettingsService через порт <see cref="IRatesChangedListener"/>
|
||||
/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12).
|
||||
/// <para>
|
||||
/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не
|
||||
/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость
|
||||
/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса
|
||||
/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401,
|
||||
/// а не 500 при резолве.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SettingsEndpoints
|
||||
{
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
private const string SettingsPath = "/settings";
|
||||
private const string SettingsOpenApiTag = "settings";
|
||||
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует GET/PATCH /api/settings.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(ApiGroupPrefix).WithTags(SettingsOpenApiTag);
|
||||
|
||||
group.MapGet(SettingsPath, GetSettingsAsync);
|
||||
group.MapPatch(SettingsPath, PatchSettingsAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/settings: публичный снимок настроек текущего тенанта.
|
||||
private static async Task<IResult> GetSettingsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
||||
return Results.Ok(await settingsService.GetPublicAsync(ct));
|
||||
}
|
||||
|
||||
// PATCH /api/settings: частичное обновление настроек; ответ — полный снимок после применения.
|
||||
private static async Task<IResult> PatchSettingsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт).
|
||||
Dictionary<string, JsonElement>? body;
|
||||
try
|
||||
{
|
||||
body = await JsonSerializer.DeserializeAsync<Dictionary<string, JsonElement>>(
|
||||
context.Request.Body,
|
||||
options: null,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Не-JSON или не-объект целиком — ошибка запроса: 400 + detail
|
||||
// (в прототипе FastAPI на такое тело — 422).
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
||||
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
|
||||
|
||||
// Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
|
||||
|
||||
// Смена источника курсов в PATCH (settings_routes.py L188–189) — фоновое обновление кэша
|
||||
// курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource.
|
||||
if (ShouldScheduleRatesRefresh(body))
|
||||
{
|
||||
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
|
||||
}
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188).
|
||||
// body: Тело PATCH — публичные ключи §4.6.
|
||||
// Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка).
|
||||
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
|
||||
{
|
||||
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет.
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()),
|
||||
JsonValueKind.Null => false,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ public static class StorageEndpoints
|
||||
// PumpOnceAsync (сбой не роняет тик) → SSE new_card по созданным карточкам → queue. Формы — 1:1 с прототипом.
|
||||
private static async Task<IResult> AdminTickAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ public static class StorageEndpoints
|
||||
// is_ready() → ready) — кнопка Settings фронта показывает ошибку по ready (store.js L1883–1889).
|
||||
private static async Task<IResult> FtsRebuildAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -84,8 +84,4 @@ public static class StorageEndpoints
|
||||
bool ok = await fts.RebuildAsync(ct);
|
||||
return Results.Ok(new { ok, ready = ok });
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@ public static class TelegramQrImageEndpoint
|
||||
// GET /api/tg/qr-image: SVG QR-кода фазы входа «qr» (tg_routes.py L30–39).
|
||||
private static async Task<IResult> QrImageAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
@@ -81,8 +81,4 @@ public static class TelegramQrImageEndpoint
|
||||
context.Response.Headers.ContentDisposition = "inline";
|
||||
return Results.Text(svg, SvgMediaType);
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@ public static class AuthHelpers
|
||||
public static CurrentUser? GetCurrentUser(this HttpContext context) =>
|
||||
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>
|
||||
/// Кладёт оператора в <c>HttpContext.Items</c>.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
@@ -128,7 +127,7 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
// Private/loopback/link-local литералы и localhost запрещены для не-local провайдеров (локальные
|
||||
// провайдеры — ветка IsLocal выше, HTTP для них не выполняется вовсе). DNS-имена не резолвятся
|
||||
// здесь (полный egress-контроль с резолвом — на уровне сетевого периметра/прокси).
|
||||
if (IsPrivateEndpoint(modelsUri))
|
||||
if (modelsUri.IsPrivateEndpoint())
|
||||
{
|
||||
return BuildResult(request, name, ok: false, message: PrivateEndpointNotAllowedMessage);
|
||||
}
|
||||
@@ -247,50 +246,8 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
}
|
||||
|
||||
// Проверяет, указывает ли URL на приватный/loopback/link-local адрес (SSRF-гейт).
|
||||
// Распознаются IP-литералы (IPv4/IPv6) и имя localhost; DNS-имена считаются публичными
|
||||
// (полный egress-контроль с резолвом выполняется на сетевом периметре).
|
||||
// uri: Абсолютный http(s)-адрес.
|
||||
// Возвращает: True — адрес приватный/локальный (HTTP к нему запрещён).
|
||||
private static bool IsPrivateEndpoint(Uri uri)
|
||||
{
|
||||
string host = uri.Host;
|
||||
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!IPAddress.TryParse(host, out IPAddress? address))
|
||||
{
|
||||
return false; // DNS-имя — резолв вне этого слоя
|
||||
}
|
||||
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
{
|
||||
address = address.MapToIPv4();
|
||||
}
|
||||
|
||||
if (IPAddress.IsLoopback(address))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
return bytes[0] == 10
|
||||
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|
||||
|| (bytes[0] == 192 && bytes[1] == 168)
|
||||
|| bytes[0] == 169 && bytes[1] == 254 // link-local (включая 169.254.169.254 metadata)
|
||||
|| bytes[0] == 0;
|
||||
}
|
||||
|
||||
// IPv6: уникальные локальные (fc00::/7) и link-local (fe80::/10).
|
||||
byte[] v6 = address.GetAddressBytes();
|
||||
return (v6[0] & 0xFE) == 0xFC || (v6[0] == 0xFE && (v6[1] & 0xC0) == 0x80);
|
||||
}
|
||||
/// <param name="exception">Исключение HTTP-слоя.</param>
|
||||
/// <returns>Человекочитаемый текст причины.</returns>
|
||||
// Исключение HTTP-слоя.
|
||||
// Возвращает: Человекочитаемый текст причины.
|
||||
private static string ConnectionErrorDetail(HttpRequestException exception)
|
||||
{
|
||||
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,
|
||||
Serving: response.Status == HealthCheckResponse.Types.ServingStatus.Serving);
|
||||
}
|
||||
catch (RpcException exception) when (IsCommunicationFailure(exception))
|
||||
catch (RpcException exception) when (exception.IsCommunicationFailure())
|
||||
{
|
||||
return ServiceHealthResult.Unreachable;
|
||||
}
|
||||
@@ -73,10 +73,4 @@ public sealed class ServiceHealthProbe
|
||||
return ServiceHealthResult.Unreachable;
|
||||
}
|
||||
}
|
||||
|
||||
// Ошибки коммуникации, при которых сервис считается недоступным (всё, кроме прикладных статусов).
|
||||
// exception: Исключение RPC.
|
||||
// Возвращает: True — транспорта/контракта health нет (down), false — прикладной статус (не наша зона).
|
||||
private static bool IsCommunicationFailure(RpcException exception) =>
|
||||
exception.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded or StatusCode.Unimplemented;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ public static class FileStorageRegistrar
|
||||
|
||||
// MinIO-режим: только когда секция/алиасы заполнены (Ruling 4: «заглушка-адаптер, если MinIO
|
||||
// недоступен» — dev/curl/unit по умолчанию работают на LocalFileStorage без MinIO).
|
||||
if (MinioConfigured(options.Minio))
|
||||
if (options.Minio.IsConfigured())
|
||||
{
|
||||
services.AddSingleton<IFileStorage>(serviceProvider =>
|
||||
new MinioFileStorage(options.Minio, serviceProvider.GetRequiredService<ILogger<MinioFileStorage>>()));
|
||||
@@ -113,20 +113,12 @@ public static class FileStorageRegistrar
|
||||
|
||||
if (secureRaw is not null)
|
||||
{
|
||||
options.Minio.Secure = IsTrue(secureRaw);
|
||||
options.Minio.Secure = secureRaw.IsTrue();
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
// True — секция Minio заполнена настолько, что возможен Minio-адаптер (Ruling 4: Endpoint + креды).
|
||||
private static bool MinioConfigured(MinioStorageOptions minio)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(minio.Endpoint)
|
||||
&& !string.IsNullOrWhiteSpace(minio.AccessKey)
|
||||
&& !string.IsNullOrWhiteSpace(minio.SecretKey);
|
||||
}
|
||||
|
||||
// Резолвит корень локального хранилища: дефолт data/attachments под ContentRoot; относительный Root — под ContentRoot; абсолютный — как есть.
|
||||
private static string ResolveLocalRoot(LocalStorageOptions local, string contentRootPath)
|
||||
{
|
||||
@@ -172,8 +164,4 @@ public static class FileStorageRegistrar
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsTrue(string raw)
|
||||
{
|
||||
return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 L289–290: пустой патч — возврат текущей задачи без записи (updated_at не бампается).
|
||||
return current;
|
||||
@@ -268,16 +268,6 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
|
||||
};
|
||||
}
|
||||
|
||||
// Проверка: патч содержит хотя бы одно изменяемое поле (python L289 «if not cols»).
|
||||
// patch: Нормализованный патч.
|
||||
// Возвращает: True — есть поле к записи.
|
||||
private static bool HasChanges(DiscoveryTaskPatch patch)
|
||||
{
|
||||
return patch.Name is not null || patch.Description is not null || patch.Keywords is not null
|
||||
|| patch.MinSubscribers is not null || patch.Lang is not null || patch.Threshold is not null
|
||||
|| patch.SampleSize is not null || patch.PlanJoins is not null || patch.AutoJoin is not null;
|
||||
}
|
||||
|
||||
// Очищает список ключей: Trim + без пустых (python L226–227).
|
||||
// keywords: Сырые ключи (null — пусто).
|
||||
// Возвращает: Список непустых ключей.
|
||||
|
||||
@@ -148,13 +148,13 @@ public static class BudgetNormalizer
|
||||
return direct;
|
||||
}
|
||||
|
||||
string letters = new string(s.Where(IsCurrencyLetter).ToArray());
|
||||
string letters = new string(s.Where(c => c.IsCurrencyLetter()).ToArray());
|
||||
if (CurrencyAliases.TryGetValue(letters, out string? fromLetters))
|
||||
{
|
||||
return fromLetters;
|
||||
}
|
||||
|
||||
if (s.Length == 3 && s.All(IsCurrencyLetter))
|
||||
if (s.Length == 3 && s.All(c => c.IsCurrencyLetter()))
|
||||
{
|
||||
return s;
|
||||
}
|
||||
@@ -162,14 +162,6 @@ public static class BudgetNormalizer
|
||||
return null;
|
||||
}
|
||||
|
||||
// Буква кода валюты: латиница A–Z или кириллица А–Я (regex прототипа [^A-ZА-Я], ai.py L286).
|
||||
// c: Символ (строка уже в верхнем регистре).
|
||||
// Возвращает: True — буква, участвующая в распознавании валюты.
|
||||
private static bool IsCurrencyLetter(char c)
|
||||
{
|
||||
return c is >= 'A' and <= 'Z' or >= 'А' and <= 'Я';
|
||||
}
|
||||
|
||||
// Конвертация суммы через курсы к рублю; null rates → null (курсов нет — граница не конвертируется).
|
||||
// amount: Сумма.
|
||||
// fromCurrency: Исходная валюта (код).
|
||||
|
||||
@@ -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> types = NormalizeTerms(rules.Types);
|
||||
IReadOnlyList<string> typeTerms = TypeAliases.ExpandTerms(types);
|
||||
bool budgetEnabled = HasBudget(rules.Budget);
|
||||
bool pricesEnabled = HasBudget(rules.Prices);
|
||||
bool budgetEnabled = rules.Budget.HasBudget();
|
||||
bool pricesEnabled = rules.Prices.HasBudget();
|
||||
|
||||
bool anyEnabled = keywords.Count > 0 || stack.Count > 0 || direction.Count > 0 || grade.Count > 0
|
||||
|| levels.Count > 0 || locations.Count > 0 || types.Count > 0 || budgetEnabled || pricesEnabled;
|
||||
@@ -157,38 +157,17 @@ public static class ColumnMatcher
|
||||
return false;
|
||||
}
|
||||
|
||||
return HasAnyTerm(rules.Direction)
|
||||
|| HasAnyTerm(rules.Keywords)
|
||||
|| HasAnyTerm(rules.Stack)
|
||||
|| HasAnyTerm(rules.Grade)
|
||||
|| HasAnyTerm(rules.Levels)
|
||||
|| HasAnyTerm(rules.Locations)
|
||||
|| HasAnyTerm(rules.Types)
|
||||
return rules.Direction.HasAnyTerm()
|
||||
|| rules.Keywords.HasAnyTerm()
|
||||
|| rules.Stack.HasAnyTerm()
|
||||
|| rules.Grade.HasAnyTerm()
|
||||
|| rules.Levels.HasAnyTerm()
|
||||
|| rules.Locations.HasAnyTerm()
|
||||
|| rules.Types.HasAnyTerm()
|
||||
|| (rules.Budget is not null && (rules.Budget.From is not null || rules.Budget.To is not null))
|
||||
|| (rules.Prices is not null && (rules.Prices.From is not null || rules.Prices.To is not null));
|
||||
}
|
||||
|
||||
// Есть ли в списке непустой (после trim) терм.
|
||||
// terms: Список термов группы.
|
||||
// Возвращает: True — хотя бы один терм непустой.
|
||||
private static bool HasAnyTerm(IReadOnlyList<string>? terms)
|
||||
{
|
||||
if (terms is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (string? raw in terms)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Совпал ли хотя бы один терм (подстрока в тексте).
|
||||
// lower: Текст в нижнем регистре (очищен от ссылок).
|
||||
// terms: Термы в нижнем регистре.
|
||||
@@ -252,14 +231,4 @@ public static class ColumnMatcher
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Активна ли бюджетная группа: объект есть и не «пустой» (прототип bool(budget) — {} выключен,
|
||||
// {cur} без границ включён, но границ не задаёт — группа совпадает всегда).
|
||||
// budget: Поле budget правил.
|
||||
// Возвращает: True — группа бюджета участвует в матчинге.
|
||||
private static bool HasBudget(BudgetRangeDto? budget)
|
||||
{
|
||||
return budget is not null
|
||||
&& (budget.From is not null || budget.To is not null || !string.IsNullOrWhiteSpace(budget.Cur));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 L137–145).
|
||||
/// </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;
|
||||
}
|
||||
|
||||
if (IsEmojiCodePoint(codePoint))
|
||||
if (codePoint.IsEmojiCodePoint())
|
||||
{
|
||||
index += length - 1;
|
||||
continue;
|
||||
@@ -245,17 +245,6 @@ public static class MessageTextCleaner
|
||||
return current;
|
||||
}
|
||||
|
||||
// Входит ли кодовая точка в эмодзи-диапазоны прототипа (L137–145).
|
||||
// codePoint: Кодовая точка (BMP или доп. плоскость).
|
||||
// Возвращает: True — декоративный символ, подлежащий удалению.
|
||||
private static bool IsEmojiCodePoint(int codePoint)
|
||||
{
|
||||
return codePoint is >= 0x1F000 and <= 0x1FAFF
|
||||
or >= 0x2600 and <= 0x27BF
|
||||
or >= 0x2B00 and <= 0x2BFF
|
||||
or 0xFE0F;
|
||||
}
|
||||
|
||||
// Обрезка по границе последнего переноса/пробела в первых limit кодовых точках
|
||||
// (прототип L184–192): выбирается перенос (или пробел), если он после середины лимита; иначе режем жёстко.
|
||||
// value: Текст длиннее лимита.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
/// <summary>
|
||||
/// Расширения строк для разбора текста сообщений.
|
||||
/// </summary>
|
||||
internal static class StringExtensions
|
||||
{
|
||||
// Служебные строки/фразы футеров агрегаторов: «суть» с ними — не структура, а шум (python L288–291).
|
||||
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); недостающие блоки пропускаются. Если структурированных полей нет
|
||||
/// (<see cref="ParsedCardContent.Summary"/> от локального/старого разбора) — суть сохраняется как есть, но
|
||||
/// отбрасывается, когда похожа на служебный футер агрегаторов (<see cref="FooterHints"/>); тогда «О задаче»
|
||||
/// отбрасывается, когда похожа на служебный футер агрегаторов (<see cref="StringExtensions.ContainsFooterHint(string)"/>); тогда «О задаче»
|
||||
/// собирается из содержательных строк текста (<see cref="LocalSummary"/>, с префиксом «О задаче: »).
|
||||
/// </remarks>
|
||||
public static class SummaryComposer
|
||||
@@ -43,13 +43,6 @@ public static class SummaryComposer
|
||||
"вакансия", "вакансию", "фриланс",
|
||||
};
|
||||
|
||||
// Служебные строки/фразы футеров агрегаторов: «суть» с ними — не структура, а шум (python L288–291).
|
||||
private static readonly string[] FooterHintsArray =
|
||||
{
|
||||
"откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите",
|
||||
"больше заявок", "узнать подробнее", "написать в лс", "пишите в лс",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Собирает «О заявке» из структурированных полей либо текста (python compose_summary L225–284).
|
||||
/// </summary>
|
||||
@@ -105,7 +98,7 @@ public static class SummaryComposer
|
||||
// Структурированных полей нет. «summary» разбора часто является копией исходника/шумом — если в нём есть
|
||||
// футеры/хэштеги-мусор, не используем его (python L264–268).
|
||||
string legacy = MessageTextCleaner.CleanShort(source.Summary);
|
||||
if (legacy.Length > 0 && !ContainsFooterHint(legacy))
|
||||
if (legacy.Length > 0 && !legacy.ContainsFooterHint())
|
||||
{
|
||||
return legacy;
|
||||
}
|
||||
@@ -121,7 +114,7 @@ public static class SummaryComposer
|
||||
}
|
||||
|
||||
string lower = line.ToLowerInvariant();
|
||||
if (line.StartsWith('#') || lower.StartsWith("**#") || ContainsFooterHint(lower))
|
||||
if (line.StartsWith('#') || lower.StartsWith("**#") || lower.ContainsFooterHint())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -205,21 +198,4 @@ public static class SummaryComposer
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Содержит ли текст служебный футер-хинт (python L267/L275: сравнение с casefold-текстом).
|
||||
// text: Текст (в любом регистре).
|
||||
// Возвращает: True — текст похож на футер агрегатора/служебную строку.
|
||||
private static bool ContainsFooterHint(string text)
|
||||
{
|
||||
string lower = text.ToLowerInvariant();
|
||||
foreach (string hint in FooterHintsArray)
|
||||
{
|
||||
if (lower.Contains(hint, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
if (active is not null)
|
||||
{
|
||||
if (IsExpired(active))
|
||||
if (active.IsExpired())
|
||||
{
|
||||
await inviteStore.UpdateStatusAsync(active.Code, InviteStatuses.Expired, null, ct);
|
||||
}
|
||||
@@ -143,7 +143,7 @@ public sealed partial class InvitesService(IInviteStore inviteStore)
|
||||
public async Task<InviteDto?> GetByCodeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
var invite = await inviteStore.GetByCodeAsync(code, ct);
|
||||
if (invite is null || invite.Status != InviteStatuses.Pending || !IsExpired(invite))
|
||||
if (invite is null || invite.Status != InviteStatuses.Pending || !invite.IsExpired())
|
||||
{
|
||||
return invite;
|
||||
}
|
||||
@@ -183,9 +183,4 @@ public sealed partial class InvitesService(IInviteStore inviteStore)
|
||||
&& normalized.Length <= MaxEmailLength
|
||||
&& EmailFormatRegex().IsMatch(normalized);
|
||||
}
|
||||
|
||||
// Истекло ли приглашение (сравнение по UTC-now; действует только для статуса pending).
|
||||
// invite: Приглашение.
|
||||
// Возвращает: true, если срок действия уже прошёл.
|
||||
private static bool IsExpired(InviteDto invite) => invite.ExpiresAt <= DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
@@ -87,20 +87,6 @@ public sealed class SuspiciousActivityService
|
||||
/// </summary>
|
||||
public const string SeverityMedium = "medium";
|
||||
|
||||
// События аудита «неудачный вход» (тенант/оператор).
|
||||
private static readonly string[] FailedLoginEvents =
|
||||
{
|
||||
AuditEvents.TenantLoginFailed,
|
||||
AuditEvents.OperatorLoginFailed,
|
||||
};
|
||||
|
||||
// События аудита «успешный вход» (тенант/оператор).
|
||||
private static readonly string[] SuccessfulLoginEvents =
|
||||
{
|
||||
AuditEvents.TenantLoginOk,
|
||||
AuditEvents.OperatorLoginOk,
|
||||
};
|
||||
|
||||
private readonly IAuditLogStore _store;
|
||||
private readonly Func<DateTimeOffset> _clock;
|
||||
|
||||
@@ -181,7 +167,7 @@ public sealed class SuspiciousActivityService
|
||||
// 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)
|
||||
{
|
||||
if (count >= FailedLoginsPerIpThreshold)
|
||||
@@ -201,7 +187,7 @@ public sealed class SuspiciousActivityService
|
||||
// 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)
|
||||
{
|
||||
if (count >= FailedLoginsPerLoginThreshold)
|
||||
@@ -224,7 +210,7 @@ public sealed class SuspiciousActivityService
|
||||
var ipsByActor = new Dictionary<Guid, HashSet<string>>();
|
||||
foreach (AuditRecordDto record in records)
|
||||
{
|
||||
if (!IsSuccessfulLogin(record) || record.ActorId is not { } actorId || string.IsNullOrWhiteSpace(record.Ip))
|
||||
if (!record.IsSuccessfulLogin() || record.ActorId is not { } actorId || string.IsNullOrWhiteSpace(record.Ip))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -260,7 +246,7 @@ public sealed class SuspiciousActivityService
|
||||
var counts = new Dictionary<Guid, int>();
|
||||
foreach (AuditRecordDto record in records)
|
||||
{
|
||||
if (!IsFailedLogin(record) || record.TenantId is not { } tenantId)
|
||||
if (!record.IsFailedLogin() || record.TenantId is not { } tenantId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -312,16 +298,6 @@ public sealed class SuspiciousActivityService
|
||||
return counts;
|
||||
}
|
||||
|
||||
// Неудачный вход (тенант/оператор)?
|
||||
// record: Запись аудита.
|
||||
// Возвращает: True — событие из FailedLoginEvents.
|
||||
private static bool IsFailedLogin(AuditRecordDto record) => FailedLoginEvents.Contains(record.EventType);
|
||||
|
||||
// Успешный вход (тенант/оператор)?
|
||||
// record: Запись аудита.
|
||||
// Возвращает: True — событие из SuccessfulLoginEvents.
|
||||
private static bool IsSuccessfulLogin(AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType);
|
||||
|
||||
// Извлекает логин из DetailJson записи (поле login); сбой разбора — null.
|
||||
// record: Запись аудита.
|
||||
// Возвращает: Логин либо null (деталей нет/не строка/повреждённый JSON).
|
||||
|
||||
@@ -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);
|
||||
|
||||
// ── колонка/спам: без 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)
|
||||
{
|
||||
return TakeFalse(state, typeDecision);
|
||||
@@ -288,11 +288,6 @@ public static class OnlineNaiveBayes
|
||||
private static Dictionary<string, double>? TryGetTerms(ModelState state, string label)
|
||||
=> 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: Готовность модели на момент вызова.
|
||||
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.
|
||||
// exception: Необработанное исключение операции.
|
||||
private static SessionException ToSessionFailure(Exception exception)
|
||||
=> IsTransportFailure(exception)
|
||||
=> exception.IsTransportFailure()
|
||||
? new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception)
|
||||
: new SessionException(StatusCode.Internal, SessionErrorMessages.InternalError, exception);
|
||||
|
||||
// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE (безопасный повтор).
|
||||
// exception: Исключение для классификации.
|
||||
private static bool IsTransportFailure(Exception exception)
|
||||
=> exception is HttpRequestException or IOException or TimeoutException or RpcException;
|
||||
|
||||
// Фаза AuthPhase → строка канона контракта (idle|phone|code|password|qr|ready).
|
||||
// phase: Фаза сессии.
|
||||
private static string PhaseToString(AuthPhase phase)
|
||||
|
||||
Reference in New Issue
Block a user