Вынести условия-предикаты в 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).
|
// «похожие колонки уже есть» — за адаптером 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, <col>:{count,new}, learning, ml, ai} (L161–163, §4.1 L257).
|
// 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)
|
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 (L203–207); ответ {ok:true}; 404.
|
// POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203–207); ответ {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 + комментарии; L217–221); ответ {ok:true}; 404.
|
// DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217–221); ответ {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 L254–256). Ответ {leads, messages: []}.
|
// 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)
|
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 L141–143).
|
// GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141–143).
|
||||||
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 L146–151; дефолты — в сервисе).
|
// POST /api/discovery/tasks: создать задачу поиска (create_task L146–151; дефолты — в сервисе).
|
||||||
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 L154–161; 404/400).
|
// 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)
|
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 L164–168).
|
// DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164–168).
|
||||||
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 L171–179; пустые ключи → 400).
|
// 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)
|
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 L181–187).
|
// POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181–187).
|
||||||
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 L216–224; невалидный статус — пустой список).
|
// new|review|joined|rejected (list_candidates L216–224; невалидный статус — пустой список).
|
||||||
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 L253–264; уже вступившего — нельзя, 400).
|
// (reject_candidate L253–264; уже вступившего — нельзя, 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 L269–271).
|
// GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269–271).
|
||||||
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 L274–277).
|
// DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274–277).
|
||||||
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 L282–285), события от новых к старым.
|
// GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282–285), события от новых к старым.
|
||||||
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 L267–284).
|
// 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)
|
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ public static class MlEndpoints
|
|||||||
// GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L66–75).
|
// GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L66–75).
|
||||||
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 L78–81).
|
// POST /api/ml/reset: полный сброс модели + очистка очереди обучения (ml_routes.py L78–81).
|
||||||
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 L84–90).
|
// POST /api/ml/predict: проверка ML на тексте (ml_routes.py L84–90).
|
||||||
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 L112–134).
|
// POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112–134).
|
||||||
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 L137–171).
|
// POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137–171).
|
||||||
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 L17–20, stats L315–320).
|
// 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)
|
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 L201–202. limit — дефолт 100, clamp 1..500 делает сервис (ListQueueAsync).
|
// rejected_count L201–202. 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 L45–49, clear_all L120–125).
|
// POST /api/pipeline/rejected/clear: полная безвозвратная очистка отсева (processing_routes.py L45–49, clear_all L120–125).
|
||||||
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 L229–232).
|
// POST /api/rates/refresh: принудительное обновление; ответ {ok, rates} (1:1 settings_routes.py L229–232).
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,132 +1,128 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Deal.Api;
|
using Deal.Api;
|
||||||
using Deal.Api.Http;
|
using Deal.Api.Http;
|
||||||
using Deal.Modules.Settings.Application;
|
using Deal.Modules.Settings.Application;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
using Deal.Modules.Tenants.Application;
|
using Deal.Modules.Tenants.Application;
|
||||||
|
|
||||||
namespace Deal.Api.Endpoints;
|
namespace Deal.Api.Endpoints;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6).
|
/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3);
|
/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3);
|
||||||
/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения
|
/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения
|
||||||
/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию:
|
/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию:
|
||||||
/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH
|
/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH
|
||||||
/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400).
|
/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400).
|
||||||
/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое
|
/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое
|
||||||
/// обновление кэша курсов (<see cref="RatesRefreshScheduler"/>, Ruling 6); пересчёт карточек при смене
|
/// обновление кэша курсов (<see cref="RatesRefreshScheduler"/>, Ruling 6); пересчёт карточек при смене
|
||||||
/// targetCurrency/conversionOn выполняет сам SettingsService через порт <see cref="IRatesChangedListener"/>
|
/// targetCurrency/conversionOn выполняет сам SettingsService через порт <see cref="IRatesChangedListener"/>
|
||||||
/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12).
|
/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12).
|
||||||
/// <para>
|
/// <para>
|
||||||
/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не
|
/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не
|
||||||
/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость
|
/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость
|
||||||
/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса
|
/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса
|
||||||
/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401,
|
/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401,
|
||||||
/// а не 500 при резолве.
|
/// а не 500 при резолве.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class SettingsEndpoints
|
public static class SettingsEndpoints
|
||||||
{
|
{
|
||||||
private const string ApiGroupPrefix = "/api";
|
private const string ApiGroupPrefix = "/api";
|
||||||
private const string SettingsPath = "/settings";
|
private const string SettingsPath = "/settings";
|
||||||
private const string SettingsOpenApiTag = "settings";
|
private const string SettingsOpenApiTag = "settings";
|
||||||
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Регистрирует GET/PATCH /api/settings.
|
/// Регистрирует GET/PATCH /api/settings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||||
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var group = app.MapGroup(ApiGroupPrefix).WithTags(SettingsOpenApiTag);
|
var group = app.MapGroup(ApiGroupPrefix).WithTags(SettingsOpenApiTag);
|
||||||
|
|
||||||
group.MapGet(SettingsPath, GetSettingsAsync);
|
group.MapGet(SettingsPath, GetSettingsAsync);
|
||||||
group.MapPatch(SettingsPath, PatchSettingsAsync);
|
group.MapPatch(SettingsPath, PatchSettingsAsync);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
||||||
return Results.Ok(await settingsService.GetPublicAsync(ct));
|
return Results.Ok(await settingsService.GetPublicAsync(ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт).
|
// Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт).
|
||||||
Dictionary<string, JsonElement>? body;
|
Dictionary<string, JsonElement>? body;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
body = await JsonSerializer.DeserializeAsync<Dictionary<string, JsonElement>>(
|
body = await JsonSerializer.DeserializeAsync<Dictionary<string, JsonElement>>(
|
||||||
context.Request.Body,
|
context.Request.Body,
|
||||||
options: null,
|
options: null,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
// Не-JSON или не-объект целиком — ошибка запроса: 400 + detail
|
// Не-JSON или не-объект целиком — ошибка запроса: 400 + detail
|
||||||
// (в прототипе FastAPI на такое тело — 422).
|
// (в прототипе FastAPI на такое тело — 422).
|
||||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body is null)
|
if (body is null)
|
||||||
{
|
{
|
||||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
||||||
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
|
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
|
||||||
|
|
||||||
// Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся.
|
// Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся.
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
|
||||||
|
|
||||||
// Смена источника курсов в PATCH (settings_routes.py L188–189) — фоновое обновление кэша
|
// Смена источника курсов в PATCH (settings_routes.py L188–189) — фоновое обновление кэша
|
||||||
// курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource.
|
// курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource.
|
||||||
if (ShouldScheduleRatesRefresh(body))
|
if (ShouldScheduleRatesRefresh(body))
|
||||||
{
|
{
|
||||||
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
|
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188).
|
// Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188).
|
||||||
// body: Тело PATCH — публичные ключи §4.6.
|
// body: Тело PATCH — публичные ключи §4.6.
|
||||||
// Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка).
|
// Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка).
|
||||||
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
|
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
|
||||||
{
|
{
|
||||||
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
|
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет.
|
// JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет.
|
||||||
return element.ValueKind switch
|
return element.ValueKind switch
|
||||||
{
|
{
|
||||||
JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()),
|
JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()),
|
||||||
JsonValueKind.Null => false,
|
JsonValueKind.Null => false,
|
||||||
_ => 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 L1883–1889).
|
// is_ready() → ready) — кнопка Settings фронта показывает ошибку по ready (store.js L1883–1889).
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
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).
|
// GET /api/tg/qr-image: SVG QR-кода фазы входа «qr» (tg_routes.py L30–39).
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 L289–290: пустой патч — возврат текущей задачи без записи (updated_at не бампается).
|
// python L289–290: пустой патч — возврат текущей задачи без записи (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 L226–227).
|
// Очищает список ключей: Trim + без пустых (python L226–227).
|
||||||
// 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 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;
|
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 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); недостающие блоки пропускаются. Если структурированных полей нет
|
/// Будет плюсом → Условия (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 L288–291).
|
|
||||||
private static readonly string[] FooterHintsArray =
|
|
||||||
{
|
|
||||||
"откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите",
|
|
||||||
"больше заявок", "узнать подробнее", "написать в лс", "пишите в лс",
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Собирает «О заявке» из структурированных полей либо текста (python compose_summary L225–284).
|
/// Собирает «О заявке» из структурированных полей либо текста (python compose_summary L225–284).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -105,7 +98,7 @@ public static class SummaryComposer
|
|||||||
// Структурированных полей нет. «summary» разбора часто является копией исходника/шумом — если в нём есть
|
// Структурированных полей нет. «summary» разбора часто является копией исходника/шумом — если в нём есть
|
||||||
// футеры/хэштеги-мусор, не используем его (python L264–268).
|
// футеры/хэштеги-мусор, не используем его (python L264–268).
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user