diff --git a/docs/spec/Код-стайл-Дейл.md b/docs/spec/Код-стайл-Дейл.md index 5296b09..0d43b58 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/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.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/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: Вызов нижестоящего обработчика.