diff --git a/docs/spec/Код-стайл-Дейл.md b/docs/spec/Код-стайл-Дейл.md index 2755cef..c0d6579 100644 --- a/docs/spec/Код-стайл-Дейл.md +++ b/docs/spec/Код-стайл-Дейл.md @@ -242,11 +242,23 @@ - `try-catch` — только для непредвиденных ошибок, не для управления ходом программы. - При пробрасывании выше — `throw;`, а **не** `throw ex;`. -- Свои исключения наследовать от `Exception`. +- **Свои доменные исключения наследовать от `DealException`** (`Deal.SharedKernel.Errors`) — базовый тип + хранит код ошибки (`ErrorCode`) и умеет брать текст из ресурсов. Состав: `NotFoundException`, + `ValidationException`, `ConflictException`, `ServiceUnavailableException`; новые — по тому же образцу. +- **Не возвращать `null` как штатный результат «не найдено»/ошибки.** Доменный сервис, у которого объект + не найден, бросает `NotFoundException` (эндпоинт отдаёт 404 через общий обработчик, а не проверкой + `is null` в каждом хендлере). `null` допустим только для **опциональных значений** — парсеры/извлечение + полей, выборки-запросы («нет строки» — нормальный результат), `Try*`-паттерн; такие методы должны быть + nullable-аннотированы и явно описаны в XML-doc. - Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к БД, неизвестные идентификаторы и т.п.). -- Все исключения должны быть залогированы или показаны пользователю; пустые `catch` запрещены. -- В лог об ошибке, как правило, писать `StackTrace`. +- Все исключения должны быть залогированы или показаны пользователю; **пустые `catch` запрещены**. +- **Единый формат лога ошибки:** понятный русский текст + структурированный контекст (операция, `tenantId`, + id сущности, `traceId`). Стектрейс пишется **только в лог**; в ответ/сообщение клиенту он не попадает — + наружу отдаётся обобщённый текст и код (обработчики на границах: `DealExceptionHandler`, gRPC-интерцептор). +- **Тексты исключений/ошибок не хардкодить** — держать в ресурсах (`ErrorMessages.resx`, доступ через + `ErrorResources.Format(ErrorResourceKeys.*)` и шаблоны `DealException`), чтобы переводы добавлялись + отдельной культурой (`.resx`-спутник) без правок кода. ## 11. Интерфейсы diff --git a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs index 0e2054e..42894a2 100644 --- a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs @@ -136,10 +136,8 @@ public static class CardDetailsEndpoints } CardsService service = context.RequestServices.GetRequiredService(); - CardDto? card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct); - return card is null - ? EndpointResults.NotFound(CardNotFoundDetail) - : await ReadCardAsync(context, card.Id, ct); + CardDto card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct); + return await ReadCardAsync(context, card.Id, ct); } // POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено». @@ -261,11 +259,7 @@ public static class CardDetailsEndpoints foreach (IFormFile file in form.Files) { await using Stream content = file.OpenReadStream(); - CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct); - if (entry is null) - { - return EndpointResults.NotFound(CardNotFoundDetail); - } + await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct); } return await ReadCardAsync(context, cardId, ct); @@ -286,11 +280,7 @@ public static class CardDetailsEndpoints } CardsService cardsService = context.RequestServices.GetRequiredService(); - CardFileDto? entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct); - if (entry is null) - { - return EndpointResults.NotFound(CardNotFoundDetail); - } + CardFileDto entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct); if (string.IsNullOrWhiteSpace(entry.ObjectKey)) { @@ -339,10 +329,8 @@ public static class CardDetailsEndpoints } CardsService cardsService = context.RequestServices.GetRequiredService(); - CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct); - return card is null - ? EndpointResults.NotFound(CardNotFoundDetail) - : await ReadCardAsync(context, cardId, ct); + await cardsService.RemoveFileAsync(cardId, fileId, ct); + return await ReadCardAsync(context, cardId, ct); } // POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка. diff --git a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs index c2f41e1..a000360 100644 --- a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs @@ -209,11 +209,7 @@ public static class CardsEndpoints } CardsService cardsService = context.RequestServices.GetRequiredService(); - CardDto? card = await cardsService.TrashCardAsync(cardId, ct); - if (card is null) - { - return EndpointResults.NotFound(CardNotFoundDetail); - } + await cardsService.TrashCardAsync(cardId, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct); return Results.Ok(new { ok = true }); @@ -230,11 +226,7 @@ public static class CardsEndpoints } CardsService cardsService = context.RequestServices.GetRequiredService(); - string? col = await cardsService.RestoreCardAsync(cardId, ct); - if (col is null) - { - return EndpointResults.NotFound(CardNotFoundDetail); - } + string col = await cardsService.RestoreCardAsync(cardId, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct); return Results.Ok(new { ok = true, col }); diff --git a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs index bbab4aa..8e63672 100644 --- a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs @@ -19,9 +19,6 @@ public static class ContainersEndpoints // OpenAPI-тег группы. private const string OpenApiTag = "containers"; - // 404 PATCH/accept: контейнер не найден. - private const string ContainerNotFoundDetail = "Контейнер не найден"; - // 400: отсутствующий/явный null name контейнера. private const string ContainerNameRequiredDetail = "Укажите название колонки"; @@ -143,7 +140,7 @@ public static class ContainersEndpoints } ContainersService containers = context.RequestServices.GetRequiredService(); - ContainerDto? updated = await containers.PatchAsync( + ContainerDto updated = await containers.PatchAsync( containerId, new ContainerPatchDto( patchBody.Name, @@ -155,10 +152,6 @@ public static class ContainersEndpoints NormalizeWireRules(patchBody.Rules), patchBody.Policy), ct); - if (updated is null) - { - return EndpointResults.NotFound(ContainerNotFoundDetail); - } await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct); return Results.Ok(new { id = updated.Id }); @@ -176,11 +169,7 @@ public static class ContainersEndpoints } ContainersService containers = context.RequestServices.GetRequiredService(); - ContainerDto? accepted = await containers.AcceptSuggestedAsync(containerId, ct); - if (accepted is null) - { - return EndpointResults.NotFound(ContainerNotFoundDetail); - } + ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct); return Results.Ok(accepted); diff --git a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs index 25a9d39..f46981d 100644 --- a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs @@ -55,8 +55,7 @@ public static class DiscoveryEndpoints // Путь лога задачи (GET). private const string TaskLogPath = "/tasks/{task_id}/log"; - private const string TaskNotFoundDetail = "Задача не найдена"; - + // 404: кандидат не найден. private const string CandidateNotFoundDetail = "Кандидат не найден"; private const string AlreadyJoinedDetail = "Уже вступили в этот источник"; @@ -149,8 +148,8 @@ public static class DiscoveryEndpoints try { DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - DiscoveryTaskDto? task = await tasks.PatchAsync(task_id, ToPatch(body), ct); - return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); + DiscoveryTaskDto task = await tasks.PatchAsync(task_id, ToPatch(body), ct); + return Results.Ok(task); } catch (DiscoveryValidationException exception) { @@ -169,8 +168,8 @@ public static class DiscoveryEndpoints } DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - bool deleted = await tasks.DeleteAsync(task_id, ct); - return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail); + await tasks.DeleteAsync(task_id, ct); + return Results.Ok(new { ok = true }); } private static async Task StartTaskAsync( @@ -186,8 +185,8 @@ public static class DiscoveryEndpoints try { DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - DiscoveryTaskDto? task = await tasks.StartAsync(task_id, ct); - return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); + DiscoveryTaskDto task = await tasks.StartAsync(task_id, ct); + return Results.Ok(task); } catch (DiscoveryValidationException exception) { @@ -206,8 +205,8 @@ public static class DiscoveryEndpoints } DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - DiscoveryTaskDto? task = await tasks.PauseAsync(task_id, ct); - return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); + DiscoveryTaskDto task = await tasks.PauseAsync(task_id, ct); + return Results.Ok(task); } private static async Task GenerateKeywordsAsync( @@ -221,11 +220,7 @@ public static class DiscoveryEndpoints } DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); - if (task is null) - { - return EndpointResults.NotFound(TaskNotFoundDetail); - } + DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct); ISettingsStore settings = context.RequestServices.GetRequiredService(); if (!await ReadAiEnabledAsync(settings, ct)) @@ -268,11 +263,7 @@ public static class DiscoveryEndpoints } DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); - if (task is null) - { - return EndpointResults.NotFound(TaskNotFoundDetail); - } + DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct); DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService(); IReadOnlyList items = await candidates.ListAsync(task_id, status, ct); @@ -403,11 +394,7 @@ public static class DiscoveryEndpoints } DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); - if (task is null) - { - return EndpointResults.NotFound(TaskNotFoundDetail); - } + DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct); DiscoveryLogService log = context.RequestServices.GetRequiredService(); IReadOnlyList items = await log.TaskLogAsync(task_id, ct); diff --git a/src/core/Deal.Api/Middleware/DealExceptionHandler.cs b/src/core/Deal.Api/Middleware/DealExceptionHandler.cs new file mode 100644 index 0000000..d119b84 --- /dev/null +++ b/src/core/Deal.Api/Middleware/DealExceptionHandler.cs @@ -0,0 +1,83 @@ +using Deal.SharedKernel.Errors; +using Deal.SharedKernel.Resources; +using Deal.SharedKernel.Tenants.Abstractions; +using Microsoft.AspNetCore.Diagnostics; + +namespace Deal.Api.Middleware; + +public sealed class DealExceptionHandler(ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + (int statusCode, string errorCode, string detail) = Resolve(exception); + LogFailure(httpContext, exception, statusCode, errorCode); + httpContext.Response.StatusCode = statusCode; + await httpContext.Response.WriteAsJsonAsync( + new { detail, code = errorCode }, + cancellationToken); + return true; + } + + // Доменные ошибки отдаются по коду; прочие — обобщённый 500 без деталей и стектрейса. + private static (int StatusCode, string ErrorCode, string Detail) Resolve(Exception exception) + => exception is DealException dealException + ? (MapStatusCode(dealException.ErrorCode), dealException.ErrorCode, dealException.Message) + : (StatusCodes.Status500InternalServerError, + DealErrorCodes.Internal, + ErrorResources.Format(ErrorResourceKeys.UnexpectedError)); + + // Код ошибки Deal → статус HTTP. + private static int MapStatusCode(string errorCode) => errorCode switch + { + DealErrorCodes.NotFound => StatusCodes.Status404NotFound, + DealErrorCodes.Validation => StatusCodes.Status400BadRequest, + DealErrorCodes.Conflict => StatusCodes.Status409Conflict, + DealErrorCodes.Unavailable => StatusCodes.Status503ServiceUnavailable, + _ => StatusCodes.Status500InternalServerError, + }; + + // Доменные ошибки — Warning без стектрейса; непредвиденные — Error со стектрейсом (только в лог). + private void LogFailure( + HttpContext context, + Exception exception, + int statusCode, + string errorCode) + { + string method = context.Request.Method; + string path = context.Request.Path.Value ?? "/"; + string tenantId = ResolveTenantId(context); + if (exception is DealException dealException) + { + logger.LogWarning( + "HTTP {Method} {Path} -> {StatusCode} {ErrorCode}; tenant={TenantId} trace={TraceId}: {Message}", + method, + path, + statusCode, + errorCode, + tenantId, + context.TraceIdentifier, + dealException.Message); + return; + } + + logger.LogError( + exception, + "HTTP {Method} {Path} -> {StatusCode} {ErrorCode}; tenant={TenantId} trace={TraceId}", + method, + path, + statusCode, + errorCode, + tenantId, + context.TraceIdentifier); + } + + // Идентификатор тенанта запроса; вне tenant-запроса — "-". + private static string ResolveTenantId(HttpContext context) + { + ITenantContext? tenantContext = context.RequestServices?.GetService(); + return tenantContext?.TenantId?.Value ?? "-"; + } +} diff --git a/src/core/Deal.Api/Program.cs b/src/core/Deal.Api/Program.cs index 38dbbf3..a1eb42e 100644 --- a/src/core/Deal.Api/Program.cs +++ b/src/core/Deal.Api/Program.cs @@ -133,6 +133,8 @@ TokenLimitDefaults tenantLimitDefaults = new( builder.Services.AddDealPersistence(tenantLimitDefaults); builder.Services.AddDealSecurity(builder.Environment.ContentRootPath); +builder.Services.AddExceptionHandler(); +builder.Services.AddProblemDetails(); MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get() ?? new MlServiceOptions(); builder.Services.AddSingleton(mlOptions); @@ -326,6 +328,7 @@ if (forwardedHeadersConfig.Enabled) app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig)); } +app.UseExceptionHandler(); app.UseMiddleware(); app.UseCors(corsPolicyName); diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs index 3a6c515..831b150 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs @@ -4,6 +4,7 @@ using Deal.Modules.Discovery.Application.Extensions; using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.SharedKernel.Errors; namespace Deal.Modules.Discovery.Application.Services; @@ -12,6 +13,9 @@ namespace Deal.Modules.Discovery.Application.Services; /// public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings) { + // Имя сущности для текста ошибки «не найдено». + private const string TaskEntityName = "Задача поиска"; + /// /// 400 create: пустое название после Trim. /// @@ -35,10 +39,12 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu /// Одна задача по id. /// /// Id задачи (dt_...). - /// Задача или null (404 «Задача не найдена» у эндпоинта). - public Task GetAsync(string taskId, CancellationToken ct) + /// Задача. + /// Задача не найдена. + public async Task GetAsync(string taskId, CancellationToken ct) { - return store.GetTaskAsync(taskId, ct); + return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false) + ?? throw new NotFoundException(TaskEntityName, taskId); } /// @@ -102,18 +108,16 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu /// /// Id задачи (dt_...). /// Изменяемые поля (null — не меняется). - /// Обновлённая задача либо null (404 «Задача не найдена»). + /// Обновлённая задача. + /// Задача не найдена. /// Новый план вне границ / бюджет исчерпан. - public async Task PatchAsync( + public async Task PatchAsync( string taskId, DiscoveryTaskPatch patch, CancellationToken ct) { - DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - if (current is null) - { - return null; - } + DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false) + ?? throw new NotFoundException(TaskEntityName, taskId); DiscoveryTaskPatch normalized = NormalizeTaskPatch(patch); if (normalized.PlanJoins is int newPlan) @@ -131,38 +135,34 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu } await store.PatchTaskAsync(taskId, normalized, ct).ConfigureAwait(false); - return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); + return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false) + ?? throw new NotFoundException(TaskEntityName, taskId); } /// /// Удаляет задачу вместе с кандидатами и логом. /// /// Id задачи (dt_...). - /// True — задача удалена; false — строки нет (404 у эндпоинта). - public async Task DeleteAsync(string taskId, CancellationToken ct) + /// Задача не найдена. + public async Task DeleteAsync(string taskId, CancellationToken ct) { - DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - if (current is null) - { - return false; - } + DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false) + ?? throw new NotFoundException(TaskEntityName, taskId); - return await store.DeleteTaskAsync(taskId, ct).ConfigureAwait(false); + await store.DeleteTaskAsync(current.Id, ct).ConfigureAwait(false); } /// /// Запускает поиск /// /// Id задачи (dt_...). - /// Задача в running либо null (404). + /// Задача в running. + /// Задача не найдена. /// Ключевых слов нет. - public async Task StartAsync(string taskId, CancellationToken ct) + public async Task StartAsync(string taskId, CancellationToken ct) { - DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - if (current is null) - { - return null; - } + DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false) + ?? throw new NotFoundException(TaskEntityName, taskId); if (current.Keywords.Count == 0) { @@ -171,24 +171,24 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu bool resetProgress = DiscoveryTaskStatuses.IsFinished(current.Status); await store.SetTaskRunningAsync(taskId, resetProgress, ct).ConfigureAwait(false); - return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); + return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false) + ?? throw new NotFoundException(TaskEntityName, taskId); } /// /// Ставит задачу на паузу. /// /// Id задачи (dt_...). - /// Задача в paused либо null (404). - public async Task PauseAsync(string taskId, CancellationToken ct) + /// Задача в paused. + /// Задача не найдена. + public async Task PauseAsync(string taskId, CancellationToken ct) { - DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - if (current is null) - { - return null; - } + DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false) + ?? throw new NotFoundException(TaskEntityName, taskId); - await store.SetTaskPausedAsync(taskId, ct).ConfigureAwait(false); - return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); + await store.SetTaskPausedAsync(current.Id, ct).ConfigureAwait(false); + return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false) + ?? throw new NotFoundException(TaskEntityName, taskId); } /// diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs index 33e0ee1..f0bfc89 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs @@ -1,5 +1,6 @@ using Deal.Contracts.Integrations.Abstractions; using Deal.Modules.Kanban.Application.Models; +using Deal.SharedKernel.Errors; namespace Deal.Modules.Kanban.Application.Services; @@ -18,6 +19,10 @@ public sealed partial class CardsService private const string DefaultAttachmentName = "file"; + // Имена сущностей для текстов ошибок «не найдено». + private const string CardEntityName = "Карточка"; + private const string CardFileEntityName = "Файл карточки"; + /// /// Добавляет файл карточке /// @@ -26,8 +31,9 @@ public sealed partial class CardsService /// MIME-тип загрузки (может быть null/пустым — детект по расширению). /// Поток содержимого файла (читается хранилищем с позиции 0). /// Длина содержимого в байтах (пишется в метаданные записи). - /// Метаданные добавленного файла или null — карточки нет (404). - public async Task AddFileAsync( + /// Метаданные добавленного файла. + /// Карточка не найдена. + public async Task AddFileAsync( string cardId, string fileName, string? contentType, @@ -37,11 +43,8 @@ public sealed partial class CardsService { ArgumentNullException.ThrowIfNull(content); - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return null; - } + CardDto card = await _store.GetCardAsync(cardId, ct) + ?? throw new NotFoundException(CardEntityName, cardId); string name = string.IsNullOrWhiteSpace(fileName) ? DefaultAttachmentName : fileName; CardFileKind kind = FileKindDetector.Detect(name, contentType); @@ -62,7 +65,7 @@ public sealed partial class CardsService // Карточка исчезла между чтением и записью (гонка): объект-сирота в хранилище не нужен — // удаляем и отвечаем 404-семантикой (DeleteAsync сбои не бросает). await _storage.DeleteAsync(objectKey, ct); - return null; + throw new NotFoundException(CardEntityName, cardId); } return entry; @@ -73,19 +76,18 @@ public sealed partial class CardsService /// /// Id карточки (c_...). /// Id записи файла (pf_...). - /// Метаданные записи файла либо null (карточка/запись не найдены). - public async Task GetFileEntryAsync( + /// Метаданные записи файла. + /// Карточка или запись файла не найдены. + public async Task GetFileEntryAsync( string cardId, string fileId, CancellationToken ct) { - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return null; - } + CardDto card = await _store.GetCardAsync(cardId, ct) + ?? throw new NotFoundException(CardEntityName, cardId); - return card.Files.FirstOrDefault(file => file.Id == fileId); + return card.Files.FirstOrDefault(file => file.Id == fileId) + ?? throw new NotFoundException(CardFileEntityName, fileId); } /// @@ -93,17 +95,15 @@ public sealed partial class CardsService /// /// Id карточки (c_...). /// Id удаляемой записи файла (pf_...). - /// Карточка после удаления (без записи) либо null — карточки нет (404-семантика). - public async Task RemoveFileAsync( + /// Карточка после удаления (без записи). + /// Карточка или запись файла не найдены. + public async Task RemoveFileAsync( string cardId, string fileId, CancellationToken ct) { - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return null; - } + CardDto card = await _store.GetCardAsync(cardId, ct) + ?? throw new NotFoundException(CardEntityName, cardId); CardFileDto? entry = card.Files.FirstOrDefault(file => file.Id == fileId); if (entry is not null && !string.IsNullOrWhiteSpace(entry.ObjectKey)) @@ -113,11 +113,11 @@ public sealed partial class CardsService if (!await _store.RemoveFileAsync(cardId, fileId, ct)) { - return null; + throw new NotFoundException(CardFileEntityName, fileId); } return await _store.GetCardAsync(cardId, ct) - ?? throw new InvalidOperationException("Карточка не прочиталась после удаления файла: " + cardId); + ?? throw new NotFoundException(CardEntityName, cardId); } private static string BuildObjectKey( diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs index df3471a..1378c99 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs @@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models; using Deal.Modules.Cards.Application.Models; using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Models; +using Deal.SharedKernel.Errors; namespace Deal.Modules.Kanban.Application.Services; @@ -87,8 +88,9 @@ public sealed partial class CardsService /// Перенос карточки в корзину /// /// Id карточки (c_...). - /// Карточка после переноса (при no-op — как была) либо null — карточки нет (404). - public Task TrashCardAsync(string cardId, CancellationToken ct) + /// Карточка после переноса (при no-op — как была). + /// Карточка не найдена. + public Task TrashCardAsync(string cardId, CancellationToken ct) { return TrashCardAsync(cardId, teach: true, ct); } @@ -98,17 +100,15 @@ public sealed partial class CardsService /// /// Id карточки (c_...). /// True — писать сигнал «спам» (действие пользователя); false — не писать. - /// Карточка после переноса (при no-op — как была) либо null — карточки нет (404). - public async Task TrashCardAsync( + /// Карточка после переноса (при no-op — как была). + /// Карточка не найдена. + public async Task TrashCardAsync( string cardId, bool teach, CancellationToken ct) { - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return null; - } + CardDto card = await _store.GetCardAsync(cardId, ct) + ?? throw new NotFoundException(CardEntityName, cardId); if (card.Col == CardIds.Trash) { @@ -122,21 +122,20 @@ public sealed partial class CardsService await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct); } - return await _store.GetCardAsync(cardId, ct); + return await _store.GetCardAsync(cardId, ct) + ?? throw new NotFoundException(CardEntityName, cardId); } /// /// Возврат карточки из архива/корзины на канбан. /// /// Id карточки (c_...). - /// Колонка возврата (inbox/доска) либо null — карточки нет (404). - public async Task RestoreCardAsync(string cardId, CancellationToken ct) + /// Колонка возврата (inbox/доска). + /// Карточка не найдена. + public async Task RestoreCardAsync(string cardId, CancellationToken ct) { - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return null; - } + CardDto card = await _store.GetCardAsync(cardId, ct) + ?? throw new NotFoundException(CardEntityName, cardId); string back = await ResolveReturnColAsync(card.PrevCol, ct); string text = LearningText(card); diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs index bce0780..69899fd 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs @@ -3,6 +3,7 @@ using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Models; +using Deal.SharedKernel.Errors; namespace Deal.Modules.Kanban.Application.Services; @@ -101,14 +102,12 @@ public sealed partial class CardsService /// «Взять в работу» /// /// Id карточки (c_...). - /// Карточка в стадии planned; null — карточки нет (404). - public async Task TakeCardAsync(string cardId, CancellationToken ct) + /// Карточка в стадии planned. + /// Карточка не найдена. + public async Task TakeCardAsync(string cardId, CancellationToken ct) { - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return null; - } + CardDto card = await _store.GetCardAsync(cardId, ct) + ?? throw new NotFoundException(CardEntityName, cardId); if (CardsDefaultContainers.Contains(card.Col)) { @@ -120,14 +119,14 @@ public sealed partial class CardsService if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct)) { // Карточка исчезла между чтением и переносом (гонка с удалением). - return null; + throw new NotFoundException(CardEntityName, cardId); } await _store.AddCommentAsync( PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct); return await _store.GetCardAsync(cardId, ct) - ?? throw new InvalidOperationException("Карточка не прочиталась после take: " + cardId); + ?? throw new NotFoundException(CardEntityName, cardId); } /// @@ -135,8 +134,9 @@ public sealed partial class CardsService /// /// Id карточки (c_...). /// Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется). - /// Обновлённая карточка или null — карточки нет (404). - public async Task PatchCardAsync( + /// Обновлённая карточка. + /// Карточка не найдена. + public async Task PatchCardAsync( string cardId, IReadOnlyDictionary body, CancellationToken ct) @@ -144,7 +144,13 @@ public sealed partial class CardsService ArgumentNullException.ThrowIfNull(body); bool updated = await _store.PatchCardAsync(cardId, ResolvePatch(body), ct); - return updated ? await _store.GetCardAsync(cardId, ct) : null; + if (!updated) + { + throw new NotFoundException(CardEntityName, cardId); + } + + return await _store.GetCardAsync(cardId, ct) + ?? throw new NotFoundException(CardEntityName, cardId); } /// diff --git a/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs b/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs index a1168a8..6d75d61 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs @@ -4,6 +4,7 @@ using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.SharedKernel.Errors; namespace Deal.Modules.Kanban.Application.Services; @@ -19,6 +20,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings) /// public const string DefaultContainerName = "Новая колонка"; + // Имя сущности для текста ошибки «не найдено». + private const string ContainerEntityName = "Контейнер"; + // Палитра колонок по умолчанию: цвет = Palette[order % 8], если цвет не задан. private static readonly string[] Palette = ["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"]; @@ -53,17 +57,15 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings) } /// - /// Один контейнер со счётчиками; null — контейнера нет. + /// Один контейнер со счётчиками /// /// Id контейнера. - /// Контейнер со счётчиками либо null. - public async Task GetAsync(string containerId, CancellationToken ct) + /// Контейнер со счётчиками. + /// Контейнер не найден. + public async Task GetAsync(string containerId, CancellationToken ct) { - ContainerDto? container = await store.GetContainerAsync(containerId, ct); - if (container is null) - { - return null; - } + ContainerDto container = await store.GetContainerAsync(containerId, ct) + ?? throw new NotFoundException(ContainerEntityName, containerId); IReadOnlyDictionary counts = await store.CountCardsByColAsync(ct); ContainerCountsDto containerCounts = counts.TryGetValue(container.Id, out CardColumnCountDto? count) @@ -107,17 +109,15 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings) /// /// Id контейнера. /// Изменения; null-поле означает «не менять». - /// Контейнер после патча; null — контейнера нет (404 «Контейнер не найден»). - public async Task PatchAsync( + /// Контейнер после патча. + /// Контейнер не найден. + public async Task PatchAsync( string containerId, ContainerPatchDto patch, CancellationToken ct) { - ContainerDto? current = await store.GetContainerAsync(containerId, ct); - if (current is null) - { - return null; - } + ContainerDto current = await store.GetContainerAsync(containerId, ct) + ?? throw new NotFoundException(ContainerEntityName, containerId); ContainerDto updated = ApplyPatch(current, patch); await store.UpdateContainerAsync(updated, ct); @@ -128,8 +128,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings) /// Принимает ИИ-предложение /// /// Id контейнера-предложения. - /// Контейнер после принятия; null — контейнера нет (404). - public Task AcceptSuggestedAsync(string containerId, CancellationToken ct) + /// Контейнер после принятия. + /// Контейнер не найден. + public Task AcceptSuggestedAsync(string containerId, CancellationToken ct) { return PatchAsync(containerId, new ContainerPatchDto( Name: null, diff --git a/src/core/Deal.SharedKernel/Errors/DealErrorCodes.cs b/src/core/Deal.SharedKernel/Errors/DealErrorCodes.cs new file mode 100644 index 0000000..85b73ba --- /dev/null +++ b/src/core/Deal.SharedKernel/Errors/DealErrorCodes.cs @@ -0,0 +1,32 @@ +namespace Deal.SharedKernel.Errors; + +/// +/// Коды ошибок Deal для логов и ответов клиенту. +/// +public static class DealErrorCodes +{ + /// + /// Запрошенный объект не найден. + /// + public const string NotFound = "not_found"; + + /// + /// Некорректные данные запроса. + /// + public const string Validation = "validation_error"; + + /// + /// Конфликт состояния. + /// + public const string Conflict = "conflict"; + + /// + /// Внешний сервис недоступен. + /// + public const string Unavailable = "unavailable"; + + /// + /// Непредвиденная внутренняя ошибка. + /// + public const string Internal = "internal_error"; +} diff --git a/src/core/Deal.SharedKernel/Errors/DealException.cs b/src/core/Deal.SharedKernel/Errors/DealException.cs new file mode 100644 index 0000000..3716abf --- /dev/null +++ b/src/core/Deal.SharedKernel/Errors/DealException.cs @@ -0,0 +1,35 @@ +using Deal.SharedKernel.Resources; + +namespace Deal.SharedKernel.Errors; + +/// +/// База доменных исключений Deal: код ошибки и текст из ресурсов. +/// +public abstract class DealException : Exception +{ + protected DealException( + string errorCode, + string messageKey, + params object?[] messageArgs) + : base(ErrorResources.Format(messageKey, messageArgs)) + { + ArgumentException.ThrowIfNullOrWhiteSpace(errorCode); + ErrorCode = errorCode; + } + + protected DealException( + string errorCode, + Exception innerException, + string messageKey, + params object?[] messageArgs) + : base(ErrorResources.Format(messageKey, messageArgs), innerException) + { + ArgumentException.ThrowIfNullOrWhiteSpace(errorCode); + ErrorCode = errorCode; + } + + /// + /// Код ошибки для логов и ответов клиенту. + /// + public string ErrorCode { get; } +} diff --git a/src/core/Deal.SharedKernel/Errors/NotFoundException.cs b/src/core/Deal.SharedKernel/Errors/NotFoundException.cs new file mode 100644 index 0000000..d784335 --- /dev/null +++ b/src/core/Deal.SharedKernel/Errors/NotFoundException.cs @@ -0,0 +1,19 @@ +using Deal.SharedKernel.Resources; + +namespace Deal.SharedKernel.Errors; + +/// +/// Запрошенный объект не найден. +/// +public sealed class NotFoundException : DealException +{ + public NotFoundException(string entityName) + : base(DealErrorCodes.NotFound, ErrorResourceKeys.NotFoundEntity, entityName) + { + } + + public NotFoundException(string entityName, string entityId) + : base(DealErrorCodes.NotFound, ErrorResourceKeys.NotFoundEntityWithId, entityName, entityId) + { + } +} diff --git a/src/core/Deal.SharedKernel/Errors/ServiceUnavailableException.cs b/src/core/Deal.SharedKernel/Errors/ServiceUnavailableException.cs new file mode 100644 index 0000000..97b3284 --- /dev/null +++ b/src/core/Deal.SharedKernel/Errors/ServiceUnavailableException.cs @@ -0,0 +1,19 @@ +using Deal.SharedKernel.Resources; + +namespace Deal.SharedKernel.Errors; + +/// +/// Внешний сервис недоступен. +/// +public sealed class ServiceUnavailableException : DealException +{ + public ServiceUnavailableException(string serviceName) + : base(DealErrorCodes.Unavailable, ErrorResourceKeys.ServiceUnavailable, serviceName) + { + } + + public ServiceUnavailableException(string serviceName, Exception innerException) + : base(DealErrorCodes.Unavailable, innerException, ErrorResourceKeys.ServiceUnavailable, serviceName) + { + } +} diff --git a/src/core/Deal.SharedKernel/Errors/ValidationException.cs b/src/core/Deal.SharedKernel/Errors/ValidationException.cs new file mode 100644 index 0000000..375677b --- /dev/null +++ b/src/core/Deal.SharedKernel/Errors/ValidationException.cs @@ -0,0 +1,14 @@ +using Deal.SharedKernel.Resources; + +namespace Deal.SharedKernel.Errors; + +/// +/// Некорректные данные запроса. +/// +public sealed class ValidationException : DealException +{ + public ValidationException(string messageKey, params object?[] messageArgs) + : base(DealErrorCodes.Validation, messageKey, messageArgs) + { + } +} diff --git a/src/core/Deal.SharedKernel/Resources/ErrorMessages.resx b/src/core/Deal.SharedKernel/Resources/ErrorMessages.resx new file mode 100644 index 0000000..225eed2 --- /dev/null +++ b/src/core/Deal.SharedKernel/Resources/ErrorMessages.resx @@ -0,0 +1,33 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Внутренняя ошибка сервиса. Обратитесь в поддержку. + + + Объект не найден: {0}. + + + Объект не найден: {0} (id: {1}). + + + Некорректные данные запроса: {0}. + + + Конфликт состояния: {0}. + + + Сервис «{0}» временно недоступен. + + diff --git a/src/core/Deal.SharedKernel/Resources/ErrorResourceKeys.cs b/src/core/Deal.SharedKernel/Resources/ErrorResourceKeys.cs new file mode 100644 index 0000000..258c3fa --- /dev/null +++ b/src/core/Deal.SharedKernel/Resources/ErrorResourceKeys.cs @@ -0,0 +1,37 @@ +namespace Deal.SharedKernel.Resources; + +/// +/// Ключи текстов ошибок Deal в ресурсах ErrorMessages.resx. +/// +public static class ErrorResourceKeys +{ + /// + /// Общая непредвиденная внутренняя ошибка. + /// + public const string UnexpectedError = "UnexpectedError"; + + /// + /// Объект не найден (без идентификатора). + /// + public const string NotFoundEntity = "NotFoundEntity"; + + /// + /// Объект не найден (с идентификатором). + /// + public const string NotFoundEntityWithId = "NotFoundEntityWithId"; + + /// + /// Некорректные данные запроса. + /// + public const string ValidationFailed = "ValidationFailed"; + + /// + /// Конфликт состояния. + /// + public const string ConflictState = "ConflictState"; + + /// + /// Внешний сервис недоступен. + /// + public const string ServiceUnavailable = "ServiceUnavailable"; +} diff --git a/src/core/Deal.SharedKernel/Resources/ErrorResources.cs b/src/core/Deal.SharedKernel/Resources/ErrorResources.cs new file mode 100644 index 0000000..0713550 --- /dev/null +++ b/src/core/Deal.SharedKernel/Resources/ErrorResources.cs @@ -0,0 +1,34 @@ +using System.Globalization; +using System.Resources; + +namespace Deal.SharedKernel.Resources; + +/// +/// Тексты ошибок Deal из ресурсов ErrorMessages.resx. +/// +public static class ErrorResources +{ + private const string ResourceBaseName = "Deal.SharedKernel.Resources.ErrorMessages"; + + private static readonly ResourceManager Manager = new(ResourceBaseName, typeof(ErrorResources).Assembly); + + /// + /// Форматированный текст по ключу ресурса с подстановкой аргументов. + /// + /// Ключ ресурса (см. ). + /// Аргументы шаблона. + /// Текст ресурса; неизвестный ключ возвращается как есть. + public static string Format(string key, params object?[] args) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + string? template = Manager.GetString(key, CultureInfo.CurrentUICulture); + if (string.IsNullOrEmpty(template)) + { + return key; + } + + return args.Length == 0 + ? template + : string.Format(CultureInfo.CurrentUICulture, template, args); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/DealExceptionHandlerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DealExceptionHandlerTests.cs new file mode 100644 index 0000000..48ec43a --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Api/DealExceptionHandlerTests.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using Deal.Api.Middleware; +using Deal.SharedKernel.Errors; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Deal.Tests.Unit.Api; + +/// +/// Тесты обработчика необработанных исключений HTTP. +/// +public sealed class DealExceptionHandlerTests +{ + [Fact] + public async Task NotFound_MapsTo404WithCodeAndRussianDetail() + { + DefaultHttpContext context = CreateContext(); + DealExceptionHandler handler = new(Substitute.For>()); + + bool handled = await handler.TryHandleAsync( + context, + new NotFoundException("Карточка", "c_1"), + CancellationToken.None); + + Assert.True(handled); + Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode); + (string detail, string code) = await ReadBodyAsync(context); + Assert.Equal(DealErrorCodes.NotFound, code); + Assert.Contains("Карточка", detail); + } + + [Fact] + public async Task Unavailable_MapsTo503() + { + DefaultHttpContext context = CreateContext(); + DealExceptionHandler handler = new(Substitute.For>()); + + await handler.TryHandleAsync( + context, + new ServiceUnavailableException("ИИ"), + CancellationToken.None); + + Assert.Equal(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode); + } + + [Fact] + public async Task UnexpectedException_MapsToGeneric500WithoutStackOrDetails() + { + DefaultHttpContext context = CreateContext(); + DealExceptionHandler handler = new(Substitute.For>()); + + bool handled = await handler.TryHandleAsync( + context, + new InvalidOperationException("секретная внутренняя деталь"), + CancellationToken.None); + + Assert.True(handled); + Assert.Equal(StatusCodes.Status500InternalServerError, context.Response.StatusCode); + (string detail, string code) = await ReadBodyAsync(context); + Assert.Equal(DealErrorCodes.Internal, code); + Assert.DoesNotContain("секретная внутренняя деталь", detail); + Assert.DoesNotContain("at ", detail); + } + + private static DefaultHttpContext CreateContext() + { + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + return context; + } + + private static async Task<(string Detail, string Code)> ReadBodyAsync(HttpContext context) + { + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(context.Response.Body); + string json = await reader.ReadToEndAsync(); + using JsonDocument document = JsonDocument.Parse(json); + return ( + document.RootElement.GetProperty("detail").GetString() ?? string.Empty, + document.RootElement.GetProperty("code").GetString() ?? string.Empty); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs index 717e77e..d6ec709 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs @@ -1,9 +1,10 @@ using Deal.Contracts.Integrations.Models; -using Deal.Tests.Unit.Support; using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; +using Deal.SharedKernel.Errors; using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Contracts; @@ -343,13 +344,13 @@ public sealed class CardsServiceTests } [Fact] - public async Task Trash_CardMissing_ReturnsNull() + public async Task Trash_CardMissing_ThrowsNotFound() { (CardsService service, _, _, TestMlClient ml) = Create(); - CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None); + await Assert.ThrowsAsync( + () => service.TrashCardAsync("l_ghost", CancellationToken.None)); - Assert.Null(result); // эндпоинт отвечает 404 «Карточка не найдена» Assert.Empty(ml.Pushed); } @@ -425,13 +426,12 @@ public sealed class CardsServiceTests } [Fact] - public async Task Restore_CardMissing_ReturnsNull() + public async Task Restore_CardMissing_ThrowsNotFound() { (CardsService service, _, _, _) = Create(); - string? back = await service.RestoreCardAsync("l_ghost", CancellationToken.None); - - Assert.Null(back); // эндпоинт отвечает 404 «Карточка не найдена» + await Assert.ThrowsAsync( + () => service.RestoreCardAsync("l_ghost", CancellationToken.None)); } diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs index d00bd44..d6ff143 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs @@ -1,8 +1,9 @@ using Deal.Modules.Discovery.Application.Exceptions; using Deal.Modules.Discovery.Application.Models; -using Deal.Tests.Unit.Support; using Deal.Modules.Discovery.Application.Services; +using Deal.SharedKernel.Errors; using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Discovery; @@ -131,14 +132,13 @@ public sealed class DiscoveryTasksServiceTests } [Fact] - public async Task Patch_MissingTask_ReturnsNull() + public async Task Patch_MissingTask_ThrowsNotFound() { (DiscoveryTasksService service, _, _) = Create(); - DiscoveryTaskDto? patched = await service.PatchAsync( - "dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None); - - Assert.Null(patched); + await Assert.ThrowsAsync( + () => service.PatchAsync( + "dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None)); } [Fact] @@ -201,13 +201,12 @@ public sealed class DiscoveryTasksServiceTests } [Fact] - public async Task Start_MissingTask_ReturnsNull() + public async Task Start_MissingTask_ThrowsNotFound() { (DiscoveryTasksService service, _, _) = Create(); - DiscoveryTaskDto? task = await service.StartAsync("dt_missing", CancellationToken.None); - - Assert.Null(task); + await Assert.ThrowsAsync( + () => service.StartAsync("dt_missing", CancellationToken.None)); } [Fact] @@ -281,9 +280,8 @@ public sealed class DiscoveryTasksServiceTests store.SeedCandidate(Candidate("c_2", "dt_2")); await store.Store.UpsertBlacklistAsync("c_1", "Источник", "причина", CancellationToken.None); - bool deleted = await service.DeleteAsync("dt_1", CancellationToken.None); + await service.DeleteAsync("dt_1", CancellationToken.None); - Assert.True(deleted); Assert.Single(store.Tasks); // dt_2 осталась Assert.Equal("dt_2", Assert.Single(store.Tasks).Id); Assert.Single(store.Candidates); // кандидат dt_2 остался @@ -292,13 +290,12 @@ public sealed class DiscoveryTasksServiceTests } [Fact] - public async Task Delete_MissingTask_ReturnsFalse() + public async Task Delete_MissingTask_ThrowsNotFound() { (DiscoveryTasksService service, _, _) = Create(); - bool deleted = await service.DeleteAsync("dt_missing", CancellationToken.None); - - Assert.False(deleted); + await Assert.ThrowsAsync( + () => service.DeleteAsync("dt_missing", CancellationToken.None)); } [Fact] diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs index cecfa82..d78ade6 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs @@ -1,6 +1,7 @@ using System.Text; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; +using Deal.SharedKernel.Errors; using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Modules.Settings; @@ -73,14 +74,13 @@ public sealed class CardsServiceFilesTests } [Fact] - public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject() + public async Task Add_CardMissing_ThrowsAndDoesNotWriteObject() { (CardsService service, TestKanjStore store, TestFileStorage storage) = Create(); - CardFileDto? entry = await service.AddFileAsync( - "c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); + await Assert.ThrowsAsync(() => service.AddFileAsync( + "c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None)); - Assert.Null(entry); // эндпоинт отвечает 404 «Карточка не найдена» Assert.Empty(storage.StoredObjectKeys); // «add на несуществующей карточке не пишет объект» Assert.Empty(store.CardDtos); } @@ -151,25 +151,23 @@ public sealed class CardsServiceFilesTests } [Fact] - public async Task GetEntry_CardMissing_ReturnsNull() + public async Task GetEntry_CardMissing_ThrowsNotFound() { (CardsService service, _, _) = Create(); - CardFileDto? entry = await service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None); - - Assert.Null(entry); // 404 «Карточка не найдена» у эндпоинта + await Assert.ThrowsAsync( + () => service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None)); } [Fact] - public async Task GetEntry_FileNotInMetadata_ReturnsNull() + public async Task GetEntry_FileNotInMetadata_ThrowsNotFound() { (CardsService service, TestKanjStore store, _) = Create(); store.SeedCard(Card("c_1") with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } }); - CardFileDto? entry = await service.GetFileEntryAsync("c_1", "pf_ghost", CancellationToken.None); - - Assert.Null(entry); // файла нет в метаданных карточки — 404-семантика + await Assert.ThrowsAsync( + () => service.GetFileEntryAsync("c_1", "pf_ghost", CancellationToken.None)); } @@ -225,13 +223,13 @@ public sealed class CardsServiceFilesTests } [Fact] - public async Task Remove_CardMissing_ReturnsNullWithoutStorageDelete() + public async Task Remove_CardMissing_ThrowsWithoutStorageDelete() { (CardsService service, _, TestFileStorage storage) = Create(); - CardDto? card = await service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None); + await Assert.ThrowsAsync( + () => service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None)); - Assert.Null(card); // эндпоинт отвечает 404 «Карточка не найдена» Assert.Empty(storage.DeletedKeys); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs index e2317c2..871d678 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; +using Deal.SharedKernel.Errors; using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Modules.Settings; @@ -99,13 +100,13 @@ public sealed class CardsServiceSelectedTests [Fact] - public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing() + public async Task TakeCard_CardMissing_ThrowsAndCreatesNothing() { (CardsService service, TestKanjStore store, _, _) = Create(); - CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None); + await Assert.ThrowsAsync( + () => service.TakeCardAsync("c_missing", CancellationToken.None)); - Assert.Null(card); Assert.Empty(store.CardDtos); } @@ -122,8 +123,7 @@ public sealed class CardsServiceSelectedTests stack: new[] { "Python", "aiogram" }, budget: new CardBudgetDto(From: 1600, To: 2200, Cur: "USD"))); - CardDto card = await service.TakeCardAsync("c_1", CancellationToken.None) - ?? throw new InvalidOperationException("take вернул null при существующей карточке"); + CardDto card = await service.TakeCardAsync("c_1", CancellationToken.None); Assert.Equal("c_1", card.Id); Assert.Equal("planned", card.Col); @@ -182,8 +182,7 @@ public sealed class CardsServiceSelectedTests ("tzText", "ТЗ"), ("stack", new[] { "C#", ".NET" }), // стек — полная замена массива ("budget", new { from = 500, cur = "EUR" })), - CancellationToken.None) - ?? throw new InvalidOperationException("patch вернул null при существующей карточке"); + CancellationToken.None); Assert.Equal("Новый заголовок", card.Title); Assert.Equal(string.Empty, card.Summary); // summary очищена пустой строкой @@ -255,16 +254,14 @@ public sealed class CardsServiceSelectedTests } [Fact] - public async Task Patch_CardMissing_ReturnsNull() + public async Task Patch_CardMissing_ThrowsNotFound() { (CardsService service, _, _, _) = Create(); - CardDto? card = await service.PatchCardAsync( + await Assert.ThrowsAsync(() => service.PatchCardAsync( "c_missing", PatchBody(("title", "Т")), - CancellationToken.None); - - Assert.Null(card); + CancellationToken.None)); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs index a329ea2..5084724 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; +using Deal.SharedKernel.Errors; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; @@ -237,14 +238,12 @@ public sealed class ContainersServiceTests } [Fact] - public async Task Patch_UnknownContainer_ReturnsNull() + public async Task Patch_UnknownContainer_ThrowsNotFound() { (ContainersService service, _, _) = Create(); - ContainerDto? result = await service.PatchAsync( - "b_missing", Patch(name: "X"), CancellationToken.None); - - Assert.Null(result); // эндпоинт отвечает 404 «Контейнер не найден» + await Assert.ThrowsAsync( + () => service.PatchAsync("b_missing", Patch(name: "X"), CancellationToken.None)); } [Fact] diff --git a/src/core/tests/Deal.Tests.Unit/Support/ErrorResourcesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ErrorResourcesTests.cs new file mode 100644 index 0000000..afeb132 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/ErrorResourcesTests.cs @@ -0,0 +1,34 @@ +using Deal.SharedKernel.Resources; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты ресурсов текстов ошибок (ErrorMessages.resx). +/// +public sealed class ErrorResourcesTests +{ + [Fact] + public void Format_KnownKey_ReturnsRussianText() + { + string text = ErrorResources.Format(ErrorResourceKeys.UnexpectedError); + + Assert.Contains("Внутренняя ошибка", text); + } + + [Fact] + public void Format_TemplateWithArgs_SubstitutesPlaceholders() + { + string text = ErrorResources.Format(ErrorResourceKeys.NotFoundEntityWithId, "Карточка", "c_1"); + + Assert.Contains("Карточка", text); + Assert.Contains("c_1", text); + } + + [Fact] + public void Format_UnknownKey_ReturnsKey() + { + string text = ErrorResources.Format("NoSuchKey"); + + Assert.Equal("NoSuchKey", text); + } +} diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj b/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj index 2bf63ff..eb8e068 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj @@ -30,6 +30,11 @@ + + + + + diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs index a7902a5..ed0804e 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs @@ -2,6 +2,8 @@ using System.Diagnostics; using Deal.Grpc.Hosting.Models; using Deal.Grpc.Hosting.Options; using Deal.Grpc.Hosting.Services; +using Deal.SharedKernel.Errors; +using Deal.SharedKernel.Resources; using Grpc.Core; using Grpc.Core.Interceptors; using Microsoft.Extensions.Logging; @@ -16,9 +18,6 @@ public sealed class RpcCallLoggingInterceptor : Interceptor // Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness). private const string HealthMethodPrefix = "/grpc.health.v1.Health/"; - // Деталь RpcException для сбоя реализации (фиксированный текст; детали ошибки не наружу). - private const string UnknownFailureDetail = "Внутренняя ошибка сервиса"; - private readonly ILogger _logger; /// @@ -101,14 +100,46 @@ public sealed class RpcCallLoggingInterceptor : Interceptor } catch (Exception exception) { - // «Прочие» сбои реализации gRPC показал бы клиенту как UNKNOWN мимо access-лога: логируем - // строку со статусом Unknown, пишем детали сбоя и переводим в RpcException (текст фиксирован). - _logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", context.Method); - LogCall(context, startedAt, StatusCode.Unknown); - throw new RpcException(new Status(StatusCode.Unknown, UnknownFailureDetail)); + RpcException mapped = MapFailure(exception, context.Method); + LogCall(context, startedAt, mapped.Status.StatusCode); + throw mapped; } } + // Переводит сбой реализации в RpcException: доменные ошибки — по коду, прочие — Unknown + // с фиксированным текстом (детали и стектрейс остаются только в логе). + // exception: Сбой обработчика. + // rpcMethod: Полное имя RPC-метода (для лога). + // Возвращает: RpcException для клиента. + private RpcException MapFailure(Exception exception, string rpcMethod) + { + if (exception is DealException dealException) + { + _logger.LogWarning( + "gRPC {RpcMethod}: доменная ошибка {ErrorCode}: {Message}", + rpcMethod, + dealException.ErrorCode, + dealException.Message); + return new RpcException(new Status(MapErrorCode(dealException.ErrorCode), dealException.Message)); + } + + _logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", rpcMethod); + return new RpcException( + new Status(StatusCode.Unknown, ErrorResources.Format(ErrorResourceKeys.UnexpectedError))); + } + + // Код ошибки Deal → статус gRPC. + // errorCode: Код из DealException.ErrorCode. + // Возвращает: Статус gRPC для клиента. + private static StatusCode MapErrorCode(string errorCode) => errorCode switch + { + DealErrorCodes.NotFound => StatusCode.NotFound, + DealErrorCodes.Validation => StatusCode.InvalidArgument, + DealErrorCodes.Conflict => StatusCode.FailedPrecondition, + DealErrorCodes.Unavailable => StatusCode.Unavailable, + _ => StatusCode.Internal, + }; + // Обёртка для handler-ов, возвращающих Task (server-streaming/дуплексный). // context: Контекст вызова (метод — context.Method). // invoke: Вызов нижестоящего обработчика.