Author SHA1 Message Date
rust 63242775ee Merge branch 'main' into t2_grpc_retry
ci / build-test (pull_request) Successful in 3m0s
2026-09-13 14:54:58 +03:00
rust beaf20df42 Merge pull request 'Запретить возврат null, унифицировать исключения и вынести тексты в ресурсы' (#20) from t16_null_exceptions_resources into main
ci / build-test (push) Successful in 3m30s
Reviewed-on: #20
2026-09-13 14:54:26 +03:00
rust 4526532b1a Merge branch 'main' into t16_null_exceptions_resources
ci / build-test (pull_request) Successful in 3m1s
2026-09-13 14:51:18 +03:00
stepan e81f1ebf30 Повторять транзиентные сбои gRPC ai/ml-клиентов
ci / build-test (pull_request) Successful in 2m56s
Filter/Classify, generate-keywords/evaluate-fit и Predict/Status оборачиваются в GrpcRetry; мутирующие вызовы (train/reset/telegram) не повторяются. Тесты учли повторы.
2026-09-13 14:42:56 +03:00
stepan babfbf8006 Добавить механизм повторов транзиентных сбоев
RetryExecutor (SharedKernel) с экспоненциальной задержкой и GrpcRetry (Infrastructure) для gRPC Unavailable/DeadlineExceeded; джиттер сглаживает синхронные ретраи воркеров. Покрыто тестами.
2026-09-13 14:42:53 +03:00
stepan 8661faea70 Перевести задачи Discovery на NotFoundException
ci / build-test (pull_request) Successful in 2m52s
DiscoveryTasksService (get/patch/delete/start/pause) бросает NotFoundException вместо null; эндпоинты отдают 404 через общий обработчик.
2026-09-13 14:32:27 +03:00
stepan 685c5fdf46 Перевести «не найдено» карточек и колонок на исключения
CardsService (карточка/файл) и ContainersService бросают NotFoundException вместо возврата null; эндпоинты больше не проверяют null — 404 отдаёт общий обработчик. Тесты обновлены под новое поведение.
2026-09-13 14:32:24 +03:00
stepan 7c43c40282 Ввести доменные исключения и ресурсы текстов ошибок
DealException/NotFoundException/Validation/Conflict/ServiceUnavailable с кодом ошибки, тексты в ErrorMessages.resx. Общий HTTP-обработчик (DealExceptionHandler) и маппинг в gRPC-интерцепторе: доменные ошибки → статус, прочие → обобщённый текст без стектрейса. Правила закреплены в код-стайле §10.
2026-09-13 14:32:20 +03:00
38 changed files with 1088 additions and 275 deletions
+15 -3
View File
@@ -242,11 +242,23 @@
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы. - `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
- При пробрасывании выше — `throw;`, а **не** `throw ex;`. - При пробрасывании выше — `throw;`, а **не** `throw ex;`.
- Свои исключения наследовать от `Exception`. - **Свои доменные исключения наследовать от `DealException`** (`Deal.SharedKernel.Errors`) — базовый тип
хранит код ошибки (`ErrorCode`) и умеет брать текст из ресурсов. Состав: `NotFoundException`,
`ValidationException`, `ConflictException`, `ServiceUnavailableException`; новые — по тому же образцу.
- **Не возвращать `null` как штатный результат «не найдено»/ошибки.** Доменный сервис, у которого объект
не найден, бросает `NotFoundException` (эндпоинт отдаёт 404 через общий обработчик, а не проверкой
`is null` в каждом хендлере). `null` допустим только для **опциональных значений** — парсеры/извлечение
полей, выборки-запросы («нет строки» — нормальный результат), `Try*`-паттерн; такие методы должны быть
nullable-аннотированы и явно описаны в XML-doc.
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к - Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
БД, неизвестные идентификаторы и т.п.). БД, неизвестные идентификаторы и т.п.).
- Все исключения должны быть залогированы или показаны пользователю; пустые `catch` запрещены. - Все исключения должны быть залогированы или показаны пользователю; **пустые `catch` запрещены**.
- В лог об ошибке, как правило, писать `StackTrace`. - **Единый формат лога ошибки:** понятный русский текст + структурированный контекст (операция, `tenantId`,
id сущности, `traceId`). Стектрейс пишется **только в лог**; в ответ/сообщение клиенту он не попадает —
наружу отдаётся обобщённый текст и код (обработчики на границах: `DealExceptionHandler`, gRPC-интерцептор).
- **Тексты исключений/ошибок не хардкодить** — держать в ресурсах (`ErrorMessages.resx`, доступ через
`ErrorResources.Format(ErrorResourceKeys.*)` и шаблоны `DealException`), чтобы переводы добавлялись
отдельной культурой (`.resx`-спутник) без правок кода.
## 11. Интерфейсы ## 11. Интерфейсы
@@ -136,10 +136,8 @@ public static class CardDetailsEndpoints
} }
CardsService service = context.RequestServices.GetRequiredService<CardsService>(); CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct); CardDto card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
return card is null return await ReadCardAsync(context, card.Id, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, card.Id, ct);
} }
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено». // POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
@@ -261,11 +259,7 @@ public static class CardDetailsEndpoints
foreach (IFormFile file in form.Files) foreach (IFormFile file in form.Files)
{ {
await using Stream content = file.OpenReadStream(); await using Stream content = file.OpenReadStream();
CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct); await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
if (entry is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
} }
return await ReadCardAsync(context, cardId, ct); return await ReadCardAsync(context, cardId, ct);
@@ -286,11 +280,7 @@ public static class CardDetailsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardFileDto? entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct); CardFileDto entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
if (entry is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
if (string.IsNullOrWhiteSpace(entry.ObjectKey)) if (string.IsNullOrWhiteSpace(entry.ObjectKey))
{ {
@@ -339,10 +329,8 @@ public static class CardDetailsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct); await cardsService.RemoveFileAsync(cardId, fileId, ct);
return card is null return await ReadCardAsync(context, cardId, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
} }
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка. // POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
+2 -10
View File
@@ -209,11 +209,7 @@ public static class CardsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.TrashCardAsync(cardId, ct); await cardsService.TrashCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
@@ -230,11 +226,7 @@ public static class CardsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
string? col = await cardsService.RestoreCardAsync(cardId, ct); string col = await cardsService.RestoreCardAsync(cardId, ct);
if (col is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
return Results.Ok(new { ok = true, col }); return Results.Ok(new { ok = true, col });
@@ -19,9 +19,6 @@ public static class ContainersEndpoints
// OpenAPI-тег группы. // OpenAPI-тег группы.
private const string OpenApiTag = "containers"; private const string OpenApiTag = "containers";
// 404 PATCH/accept: контейнер не найден.
private const string ContainerNotFoundDetail = "Контейнер не найден";
// 400: отсутствующий/явный null name контейнера. // 400: отсутствующий/явный null name контейнера.
private const string ContainerNameRequiredDetail = "Укажите название колонки"; private const string ContainerNameRequiredDetail = "Укажите название колонки";
@@ -143,7 +140,7 @@ public static class ContainersEndpoints
} }
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>(); ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto? updated = await containers.PatchAsync( ContainerDto updated = await containers.PatchAsync(
containerId, containerId,
new ContainerPatchDto( new ContainerPatchDto(
patchBody.Name, patchBody.Name,
@@ -155,10 +152,6 @@ public static class ContainersEndpoints
NormalizeWireRules(patchBody.Rules), NormalizeWireRules(patchBody.Rules),
patchBody.Policy), patchBody.Policy),
ct); ct);
if (updated is null)
{
return EndpointResults.NotFound(ContainerNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
return Results.Ok(new { id = updated.Id }); return Results.Ok(new { id = updated.Id });
@@ -176,11 +169,7 @@ public static class ContainersEndpoints
} }
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>(); ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto? accepted = await containers.AcceptSuggestedAsync(containerId, ct); ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct);
if (accepted is null)
{
return EndpointResults.NotFound(ContainerNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
return Results.Ok(accepted); return Results.Ok(accepted);
@@ -55,8 +55,7 @@ public static class DiscoveryEndpoints
// Путь лога задачи (GET). // Путь лога задачи (GET).
private const string TaskLogPath = "/tasks/{task_id}/log"; private const string TaskLogPath = "/tasks/{task_id}/log";
private const string TaskNotFoundDetail = "Задача не найдена"; // 404: кандидат не найден.
private const string CandidateNotFoundDetail = "Кандидат не найден"; private const string CandidateNotFoundDetail = "Кандидат не найден";
private const string AlreadyJoinedDetail = "Уже вступили в этот источник"; private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
@@ -149,8 +148,8 @@ public static class DiscoveryEndpoints
try try
{ {
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.PatchAsync(task_id, ToPatch(body), ct); DiscoveryTaskDto task = await tasks.PatchAsync(task_id, ToPatch(body), ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); return Results.Ok(task);
} }
catch (DiscoveryValidationException exception) catch (DiscoveryValidationException exception)
{ {
@@ -169,8 +168,8 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
bool deleted = await tasks.DeleteAsync(task_id, ct); await tasks.DeleteAsync(task_id, ct);
return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail); return Results.Ok(new { ok = true });
} }
private static async Task<IResult> StartTaskAsync( private static async Task<IResult> StartTaskAsync(
@@ -186,8 +185,8 @@ public static class DiscoveryEndpoints
try try
{ {
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.StartAsync(task_id, ct); DiscoveryTaskDto task = await tasks.StartAsync(task_id, ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); return Results.Ok(task);
} }
catch (DiscoveryValidationException exception) catch (DiscoveryValidationException exception)
{ {
@@ -206,8 +205,8 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.PauseAsync(task_id, ct); DiscoveryTaskDto task = await tasks.PauseAsync(task_id, ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); return Results.Ok(task);
} }
private static async Task<IResult> GenerateKeywordsAsync( private static async Task<IResult> GenerateKeywordsAsync(
@@ -221,11 +220,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
ISettingsStore settings = context.RequestServices.GetRequiredService<ISettingsStore>(); ISettingsStore settings = context.RequestServices.GetRequiredService<ISettingsStore>();
if (!await ReadAiEnabledAsync(settings, ct)) if (!await ReadAiEnabledAsync(settings, ct))
@@ -268,11 +263,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>(); DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
IReadOnlyList<DiscoveryCandidateDto> items = await candidates.ListAsync(task_id, status, ct); IReadOnlyList<DiscoveryCandidateDto> items = await candidates.ListAsync(task_id, status, ct);
@@ -403,11 +394,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
DiscoveryLogService log = context.RequestServices.GetRequiredService<DiscoveryLogService>(); DiscoveryLogService log = context.RequestServices.GetRequiredService<DiscoveryLogService>();
IReadOnlyList<DiscoveryLogDto> items = await log.TaskLogAsync(task_id, ct); IReadOnlyList<DiscoveryLogDto> items = await log.TaskLogAsync(task_id, ct);
@@ -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<DealExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> 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<ITenantContext>();
return tenantContext?.TenantId?.Value ?? "-";
}
}
+3
View File
@@ -133,6 +133,8 @@ TokenLimitDefaults tenantLimitDefaults = new(
builder.Services.AddDealPersistence(tenantLimitDefaults); builder.Services.AddDealPersistence(tenantLimitDefaults);
builder.Services.AddDealSecurity(builder.Environment.ContentRootPath); builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
builder.Services.AddExceptionHandler<DealExceptionHandler>();
builder.Services.AddProblemDetails();
MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions(); MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions();
builder.Services.AddSingleton(mlOptions); builder.Services.AddSingleton(mlOptions);
@@ -326,6 +328,7 @@ if (forwardedHeadersConfig.Enabled)
app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig)); app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
} }
app.UseExceptionHandler();
app.UseMiddleware<HttpAccessLogMiddleware>(); app.UseMiddleware<HttpAccessLogMiddleware>();
app.UseCors(corsPolicyName); app.UseCors(corsPolicyName);
@@ -0,0 +1,66 @@
using Deal.SharedKernel.Resilience;
using Grpc.Core;
namespace Deal.Infrastructure.Integrations.Resilience;
/// <summary>
/// Повтор транзиентных gRPC-сбоев клиентов автономных сервисов.
/// </summary>
public static class GrpcRetry
{
/// <summary>
/// Число повторов после первой попытки.
/// </summary>
public const int RetryCount = 2;
/// <summary>
/// Базовая задержка повтора (далее — экспоненциально с джиттером).
/// </summary>
public static readonly TimeSpan BaseDelay = TimeSpan.FromMilliseconds(200);
/// <summary>
/// Выполняет gRPC-вызов с повтором транзиентных сбоев.
/// </summary>
/// <param name="operation">Вызов (принимает токен отмены).</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Ответ вызова.</returns>
public static Task<TResult> ExecuteAsync<TResult>(
Func<CancellationToken, Task<TResult>> operation,
CancellationToken cancellationToken)
=> ExecuteAsync(operation, DefaultDelayAsync, cancellationToken);
/// <summary>
/// Выполняет gRPC-вызов с повтором и заданной паузой между попытками.
/// </summary>
/// <param name="operation">Вызов (принимает токен отмены).</param>
/// <param name="delayAsync">Пауза между попытками (в тестах — мгновенная).</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Ответ вызова.</returns>
public static Task<TResult> ExecuteAsync<TResult>(
Func<CancellationToken, Task<TResult>> operation,
Func<TimeSpan, CancellationToken, Task> delayAsync,
CancellationToken cancellationToken)
=> RetryExecutor.ExecuteAsync(
operation,
RetryCount,
BaseDelay,
IsTransient,
delayAsync,
cancellationToken);
/// <summary>
/// Признак транзиентного сбоя транспорта (недоступность/дедлайн).
/// </summary>
/// <param name="exception">Исключение вызова.</param>
/// <returns>True — сбой имеет смысл повторить.</returns>
public static bool IsTransient(Exception exception)
=> exception is RpcException rpc
&& rpc.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded;
// Экспоненциальная задержка с джиттером 0.5–1.5× (сглаживает синхронные ретраи воркеров).
private static Task DefaultDelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
double factor = 0.5 + Random.Shared.NextDouble();
return Task.Delay(TimeSpan.FromMilliseconds(delay.TotalMilliseconds * factor), cancellationToken);
}
}
@@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai; using Deal.Grpc.Ai;
using Deal.Infrastructure.Integrations.Exceptions; using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
@@ -75,14 +76,16 @@ public sealed class GrpcAiClassifier : IAiClassifier
string prompt = await _contextBuilder.BuildFilterPromptAsync(ct); string prompt = await _contextBuilder.BuildFilterPromptAsync(ct);
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct); ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
AiService.AiServiceClient client = _connection.CreateClient(); AiService.AiServiceClient client = _connection.CreateClient();
FilterReply reply = await client.FilterAsync( FilterReply reply = await GrpcRetry.ExecuteAsync(
token => client.FilterAsync(
new FilterRequest new FilterRequest
{ {
Prompt = prompt, Prompt = prompt,
Text = SliceCodePoints(text, MaxFilterTextCodePoints), // python L193: text[:4000] Text = SliceCodePoints(text, MaxFilterTextCodePoints), // python L193: text[:4000]
ProviderConfig = providerConfig, ProviderConfig = providerConfig,
}, },
CallOptions(tenantId.Value, ct)); CallOptions(tenantId.Value, token)).ResponseAsync,
ct);
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
return new AiFilterResultDto( return new AiFilterResultDto(
@@ -113,14 +116,16 @@ public sealed class GrpcAiClassifier : IAiClassifier
ClassifyReply reply; ClassifyReply reply;
try try
{ {
reply = await client.ClassifyAsync( reply = await GrpcRetry.ExecuteAsync(
token => client.ClassifyAsync(
new ClassifyRequest new ClassifyRequest
{ {
SystemPrompt = systemPrompt, SystemPrompt = systemPrompt,
UserContext = userContext, UserContext = userContext,
ProviderConfig = providerConfig, ProviderConfig = providerConfig,
}, },
CallOptions(tenantId.Value, ct)); CallOptions(tenantId.Value, token)).ResponseAsync,
ct);
} }
catch (RpcException exception) catch (RpcException exception)
{ {
@@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai; using Deal.Grpc.Ai;
using Deal.Infrastructure.Integrations.Exceptions; using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
using Grpc.Core; using Grpc.Core;
@@ -74,13 +75,15 @@ public sealed class GrpcAiTools : IAiTools
{ {
AiService.AiServiceClient client = _connection.CreateClient(); AiService.AiServiceClient client = _connection.CreateClient();
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct); ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync( GenerateKeywordsReply reply = await GrpcRetry.ExecuteAsync(
token => client.GenerateKeywordsAsync(
new GenerateKeywordsRequest new GenerateKeywordsRequest
{ {
Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints), Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints),
ProviderConfig = providerConfig, ProviderConfig = providerConfig,
}, },
CallOptions(tenantId.Value, ct)); CallOptions(tenantId.Value, token)).ResponseAsync,
ct);
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
return new AiGenerateKeywordsResultDto( return new AiGenerateKeywordsResultDto(
Ok: true, Ok: true,
@@ -125,7 +128,9 @@ public sealed class GrpcAiTools : IAiTools
} }
} }
EvaluateFitReply reply = await client.EvaluateFitAsync(request, CallOptions(tenantId.Value, ct)); EvaluateFitReply reply = await GrpcRetry.ExecuteAsync(
token => client.EvaluateFitAsync(request, CallOptions(tenantId.Value, token)).ResponseAsync,
ct);
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
return new AiEvaluateFitResultDto( return new AiEvaluateFitResultDto(
Fit: reply.Fit, Fit: reply.Fit,
@@ -4,6 +4,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ml; using Deal.Grpc.Ml;
using Deal.Infrastructure.Integrations.Abstractions; using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
@@ -124,9 +125,11 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
try try
{ {
MlService.MlServiceClient client = _connection.CreateClient(); MlService.MlServiceClient client = _connection.CreateClient();
PredictReply reply = await client.PredictAsync( PredictReply reply = await GrpcRetry.ExecuteAsync(
token => client.PredictAsync(
new PredictRequest { Text = text ?? string.Empty }, new PredictRequest { Text = text ?? string.Empty },
CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), ct)); CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), token)).ResponseAsync,
ct);
await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct); await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct);
return MapPredict(reply); return MapPredict(reply);
@@ -220,9 +223,11 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
try try
{ {
MlService.MlServiceClient client = _connection.CreateClient(); MlService.MlServiceClient client = _connection.CreateClient();
StatusReply reply = await client.StatusAsync( StatusReply reply = await GrpcRetry.ExecuteAsync(
token => client.StatusAsync(
new StatusRequest(), new StatusRequest(),
CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), ct)); CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), token)).ResponseAsync,
ct);
MlServiceStatusDto service = MapStatus(reply); MlServiceStatusDto service = MapStatus(reply);
_statusCache.Set(tenantId.Value, service, reachable: true); _statusCache.Set(tenantId.Value, service, reachable: true);
return _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot updated) return _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot updated)
@@ -4,6 +4,7 @@ using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Services;
@@ -12,6 +13,9 @@ namespace Deal.Modules.Discovery.Application.Services;
/// </summary> /// </summary>
public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings) public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings)
{ {
// Имя сущности для текста ошибки «не найдено».
private const string TaskEntityName = "Задача поиска";
/// <summary> /// <summary>
/// 400 create: пустое название после Trim. /// 400 create: пустое название после Trim.
/// </summary> /// </summary>
@@ -35,10 +39,12 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
/// Одна задача по id. /// Одна задача по id.
/// </summary> /// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param> /// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <returns>Задача или null (404 «Задача не найдена» у эндпоинта).</returns> /// <returns>Задача.</returns>
public Task<DiscoveryTaskDto?> GetAsync(string taskId, CancellationToken ct) /// <exception cref="NotFoundException">Задача не найдена.</exception>
public async Task<DiscoveryTaskDto> GetAsync(string taskId, CancellationToken ct)
{ {
return store.GetTaskAsync(taskId, ct); return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
?? throw new NotFoundException(TaskEntityName, taskId);
} }
/// <summary> /// <summary>
@@ -102,18 +108,16 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
/// </summary> /// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param> /// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="patch">Изменяемые поля (null — не меняется).</param> /// <param name="patch">Изменяемые поля (null — не меняется).</param>
/// <returns>Обновлённая задача либо null (404 «Задача не найдена»).</returns> /// <returns>Обновлённая задача.</returns>
/// <exception cref="NotFoundException">Задача не найдена.</exception>
/// <exception cref="DiscoveryValidationException">Новый план вне границ / бюджет исчерпан.</exception> /// <exception cref="DiscoveryValidationException">Новый план вне границ / бюджет исчерпан.</exception>
public async Task<DiscoveryTaskDto?> PatchAsync( public async Task<DiscoveryTaskDto> PatchAsync(
string taskId, string taskId,
DiscoveryTaskPatch patch, DiscoveryTaskPatch patch,
CancellationToken ct) CancellationToken ct)
{ {
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
if (current is null) ?? throw new NotFoundException(TaskEntityName, taskId);
{
return null;
}
DiscoveryTaskPatch normalized = NormalizeTaskPatch(patch); DiscoveryTaskPatch normalized = NormalizeTaskPatch(patch);
if (normalized.PlanJoins is int newPlan) 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); 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);
} }
/// <summary> /// <summary>
/// Удаляет задачу вместе с кандидатами и логом. /// Удаляет задачу вместе с кандидатами и логом.
/// </summary> /// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param> /// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <returns>True — задача удалена; false — строки нет (404 у эндпоинта).</returns> /// <exception cref="NotFoundException">Задача не найдена.</exception>
public async Task<bool> DeleteAsync(string taskId, CancellationToken ct) public async Task DeleteAsync(string taskId, CancellationToken ct)
{ {
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
if (current is null) ?? throw new NotFoundException(TaskEntityName, taskId);
{
return false;
}
return await store.DeleteTaskAsync(taskId, ct).ConfigureAwait(false); await store.DeleteTaskAsync(current.Id, ct).ConfigureAwait(false);
} }
/// <summary> /// <summary>
/// Запускает поиск /// Запускает поиск
/// </summary> /// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param> /// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <returns>Задача в running либо null (404).</returns> /// <returns>Задача в running.</returns>
/// <exception cref="NotFoundException">Задача не найдена.</exception>
/// <exception cref="DiscoveryValidationException">Ключевых слов нет.</exception> /// <exception cref="DiscoveryValidationException">Ключевых слов нет.</exception>
public async Task<DiscoveryTaskDto?> StartAsync(string taskId, CancellationToken ct) public async Task<DiscoveryTaskDto> StartAsync(string taskId, CancellationToken ct)
{ {
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
if (current is null) ?? throw new NotFoundException(TaskEntityName, taskId);
{
return null;
}
if (current.Keywords.Count == 0) if (current.Keywords.Count == 0)
{ {
@@ -171,24 +171,24 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
bool resetProgress = DiscoveryTaskStatuses.IsFinished(current.Status); bool resetProgress = DiscoveryTaskStatuses.IsFinished(current.Status);
await store.SetTaskRunningAsync(taskId, resetProgress, ct).ConfigureAwait(false); 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);
} }
/// <summary> /// <summary>
/// Ставит задачу на паузу. /// Ставит задачу на паузу.
/// </summary> /// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param> /// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <returns>Задача в paused либо null (404).</returns> /// <returns>Задача в paused.</returns>
public async Task<DiscoveryTaskDto?> PauseAsync(string taskId, CancellationToken ct) /// <exception cref="NotFoundException">Задача не найдена.</exception>
public async Task<DiscoveryTaskDto> PauseAsync(string taskId, CancellationToken ct)
{ {
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
if (current is null) ?? throw new NotFoundException(TaskEntityName, taskId);
{
return null;
}
await store.SetTaskPausedAsync(taskId, ct).ConfigureAwait(false); await store.SetTaskPausedAsync(current.Id, ct).ConfigureAwait(false);
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
?? throw new NotFoundException(TaskEntityName, taskId);
} }
/// <summary> /// <summary>
@@ -1,5 +1,6 @@
using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services; namespace Deal.Modules.Kanban.Application.Services;
@@ -18,6 +19,10 @@ public sealed partial class CardsService
private const string DefaultAttachmentName = "file"; private const string DefaultAttachmentName = "file";
// Имена сущностей для текстов ошибок «не найдено».
private const string CardEntityName = "Карточка";
private const string CardFileEntityName = "Файл карточки";
/// <summary> /// <summary>
/// Добавляет файл карточке /// Добавляет файл карточке
/// </summary> /// </summary>
@@ -26,8 +31,9 @@ public sealed partial class CardsService
/// <param name="contentType">MIME-тип загрузки (может быть null/пустым — детект по расширению).</param> /// <param name="contentType">MIME-тип загрузки (может быть null/пустым — детект по расширению).</param>
/// <param name="content">Поток содержимого файла (читается хранилищем с позиции 0).</param> /// <param name="content">Поток содержимого файла (читается хранилищем с позиции 0).</param>
/// <param name="size">Длина содержимого в байтах (пишется в метаданные записи).</param> /// <param name="size">Длина содержимого в байтах (пишется в метаданные записи).</param>
/// <returns>Метаданные добавленного файла или null — карточки нет (404).</returns> /// <returns>Метаданные добавленного файла.</returns>
public async Task<CardFileDto?> AddFileAsync( /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardFileDto> AddFileAsync(
string cardId, string cardId,
string fileName, string fileName,
string? contentType, string? contentType,
@@ -37,11 +43,8 @@ public sealed partial class CardsService
{ {
ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(content);
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return null;
}
string name = string.IsNullOrWhiteSpace(fileName) ? DefaultAttachmentName : fileName; string name = string.IsNullOrWhiteSpace(fileName) ? DefaultAttachmentName : fileName;
CardFileKind kind = FileKindDetector.Detect(name, contentType); CardFileKind kind = FileKindDetector.Detect(name, contentType);
@@ -62,7 +65,7 @@ public sealed partial class CardsService
// Карточка исчезла между чтением и записью (гонка): объект-сирота в хранилище не нужен — // Карточка исчезла между чтением и записью (гонка): объект-сирота в хранилище не нужен —
// удаляем и отвечаем 404-семантикой (DeleteAsync сбои не бросает). // удаляем и отвечаем 404-семантикой (DeleteAsync сбои не бросает).
await _storage.DeleteAsync(objectKey, ct); await _storage.DeleteAsync(objectKey, ct);
return null; throw new NotFoundException(CardEntityName, cardId);
} }
return entry; return entry;
@@ -73,19 +76,18 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="fileId">Id записи файла (<c>pf_...</c>).</param> /// <param name="fileId">Id записи файла (<c>pf_...</c>).</param>
/// <returns>Метаданные записи файла либо null (карточка/запись не найдены).</returns> /// <returns>Метаданные записи файла.</returns>
public async Task<CardFileDto?> GetFileEntryAsync( /// <exception cref="NotFoundException">Карточка или запись файла не найдены.</exception>
public async Task<CardFileDto> GetFileEntryAsync(
string cardId, string cardId,
string fileId, string fileId,
CancellationToken ct) CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return null;
}
return card.Files.FirstOrDefault(file => file.Id == fileId); return card.Files.FirstOrDefault(file => file.Id == fileId)
?? throw new NotFoundException(CardFileEntityName, fileId);
} }
/// <summary> /// <summary>
@@ -93,17 +95,15 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param> /// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param>
/// <returns>Карточка после удаления (без записи) либо null — карточки нет (404-семантика).</returns> /// <returns>Карточка после удаления (без записи).</returns>
public async Task<CardDto?> RemoveFileAsync( /// <exception cref="NotFoundException">Карточка или запись файла не найдены.</exception>
public async Task<CardDto> RemoveFileAsync(
string cardId, string cardId,
string fileId, string fileId,
CancellationToken ct) CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return null;
}
CardFileDto? entry = card.Files.FirstOrDefault(file => file.Id == fileId); CardFileDto? entry = card.Files.FirstOrDefault(file => file.Id == fileId);
if (entry is not null && !string.IsNullOrWhiteSpace(entry.ObjectKey)) 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)) if (!await _store.RemoveFileAsync(cardId, fileId, ct))
{ {
return null; throw new NotFoundException(CardFileEntityName, fileId);
} }
return await _store.GetCardAsync(cardId, ct) return await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после удаления файла: " + cardId); ?? throw new NotFoundException(CardEntityName, cardId);
} }
private static string BuildObjectKey( private static string BuildObjectKey(
@@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services; namespace Deal.Modules.Kanban.Application.Services;
@@ -87,8 +88,9 @@ public sealed partial class CardsService
/// Перенос карточки в корзину /// Перенос карточки в корзину
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns> /// <returns>Карточка после переноса (при no-op — как была).</returns>
public Task<CardDto?> TrashCardAsync(string cardId, CancellationToken ct) /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public Task<CardDto> TrashCardAsync(string cardId, CancellationToken ct)
{ {
return TrashCardAsync(cardId, teach: true, ct); return TrashCardAsync(cardId, teach: true, ct);
} }
@@ -98,17 +100,15 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param> /// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns> /// <returns>Карточка после переноса (при no-op — как была).</returns>
public async Task<CardDto?> TrashCardAsync( /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardDto> TrashCardAsync(
string cardId, string cardId,
bool teach, bool teach,
CancellationToken ct) CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return null;
}
if (card.Col == CardIds.Trash) if (card.Col == CardIds.Trash)
{ {
@@ -122,21 +122,20 @@ public sealed partial class CardsService
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct); 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);
} }
/// <summary> /// <summary>
/// Возврат карточки из архива/корзины на канбан. /// Возврат карточки из архива/корзины на канбан.
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Колонка возврата (inbox/доска) либо null — карточки нет (404).</returns> /// <returns>Колонка возврата (inbox/доска).</returns>
public async Task<string?> RestoreCardAsync(string cardId, CancellationToken ct) /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<string> RestoreCardAsync(string cardId, CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return null;
}
string back = await ResolveReturnColAsync(card.PrevCol, ct); string back = await ResolveReturnColAsync(card.PrevCol, ct);
string text = LearningText(card); string text = LearningText(card);
@@ -3,6 +3,7 @@ using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services; namespace Deal.Modules.Kanban.Application.Services;
@@ -101,14 +102,12 @@ public sealed partial class CardsService
/// «Взять в работу» /// «Взять в работу»
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка в стадии planned; null — карточки нет (404).</returns> /// <returns>Карточка в стадии planned.</returns>
public async Task<CardDto?> TakeCardAsync(string cardId, CancellationToken ct) /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardDto> TakeCardAsync(string cardId, CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return null;
}
if (CardsDefaultContainers.Contains(card.Col)) if (CardsDefaultContainers.Contains(card.Col))
{ {
@@ -120,14 +119,14 @@ public sealed partial class CardsService
if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct)) if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct))
{ {
// Карточка исчезла между чтением и переносом (гонка с удалением). // Карточка исчезла между чтением и переносом (гонка с удалением).
return null; throw new NotFoundException(CardEntityName, cardId);
} }
await _store.AddCommentAsync( await _store.AddCommentAsync(
PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct); PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct);
return await _store.GetCardAsync(cardId, ct) return await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после take: " + cardId); ?? throw new NotFoundException(CardEntityName, cardId);
} }
/// <summary> /// <summary>
@@ -135,8 +134,9 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="body">Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).</param> /// <param name="body">Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).</param>
/// <returns>Обновлённая карточка или null — карточки нет (404).</returns> /// <returns>Обновлённая карточка.</returns>
public async Task<CardDto?> PatchCardAsync( /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardDto> PatchCardAsync(
string cardId, string cardId,
IReadOnlyDictionary<string, JsonElement> body, IReadOnlyDictionary<string, JsonElement> body,
CancellationToken ct) CancellationToken ct)
@@ -144,7 +144,13 @@ public sealed partial class CardsService
ArgumentNullException.ThrowIfNull(body); ArgumentNullException.ThrowIfNull(body);
bool updated = await _store.PatchCardAsync(cardId, ResolvePatch(body), ct); 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);
} }
/// <summary> /// <summary>
@@ -4,6 +4,7 @@ using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services; namespace Deal.Modules.Kanban.Application.Services;
@@ -19,6 +20,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
/// </summary> /// </summary>
public const string DefaultContainerName = "Новая колонка"; public const string DefaultContainerName = "Новая колонка";
// Имя сущности для текста ошибки «не найдено».
private const string ContainerEntityName = "Контейнер";
// Палитра колонок по умолчанию: цвет = Palette[order % 8], если цвет не задан. // Палитра колонок по умолчанию: цвет = Palette[order % 8], если цвет не задан.
private static readonly string[] Palette = private static readonly string[] Palette =
["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"]; ["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"];
@@ -53,17 +57,15 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
} }
/// <summary> /// <summary>
/// Один контейнер со счётчиками; null — контейнера нет. /// Один контейнер со счётчиками
/// </summary> /// </summary>
/// <param name="containerId">Id контейнера.</param> /// <param name="containerId">Id контейнера.</param>
/// <returns>Контейнер со счётчиками либо null.</returns> /// <returns>Контейнер со счётчиками.</returns>
public async Task<ContainerDto?> GetAsync(string containerId, CancellationToken ct) /// <exception cref="NotFoundException">Контейнер не найден.</exception>
public async Task<ContainerDto> GetAsync(string containerId, CancellationToken ct)
{ {
ContainerDto? container = await store.GetContainerAsync(containerId, ct); ContainerDto container = await store.GetContainerAsync(containerId, ct)
if (container is null) ?? throw new NotFoundException(ContainerEntityName, containerId);
{
return null;
}
IReadOnlyDictionary<string, CardColumnCountDto> counts = await store.CountCardsByColAsync(ct); IReadOnlyDictionary<string, CardColumnCountDto> counts = await store.CountCardsByColAsync(ct);
ContainerCountsDto containerCounts = counts.TryGetValue(container.Id, out CardColumnCountDto? count) ContainerCountsDto containerCounts = counts.TryGetValue(container.Id, out CardColumnCountDto? count)
@@ -107,17 +109,15 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
/// </summary> /// </summary>
/// <param name="containerId">Id контейнера.</param> /// <param name="containerId">Id контейнера.</param>
/// <param name="patch">Изменения; null-поле означает «не менять».</param> /// <param name="patch">Изменения; null-поле означает «не менять».</param>
/// <returns>Контейнер после патча; null — контейнера нет (404 «Контейнер не найден»).</returns> /// <returns>Контейнер после патча.</returns>
public async Task<ContainerDto?> PatchAsync( /// <exception cref="NotFoundException">Контейнер не найден.</exception>
public async Task<ContainerDto> PatchAsync(
string containerId, string containerId,
ContainerPatchDto patch, ContainerPatchDto patch,
CancellationToken ct) CancellationToken ct)
{ {
ContainerDto? current = await store.GetContainerAsync(containerId, ct); ContainerDto current = await store.GetContainerAsync(containerId, ct)
if (current is null) ?? throw new NotFoundException(ContainerEntityName, containerId);
{
return null;
}
ContainerDto updated = ApplyPatch(current, patch); ContainerDto updated = ApplyPatch(current, patch);
await store.UpdateContainerAsync(updated, ct); await store.UpdateContainerAsync(updated, ct);
@@ -128,8 +128,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
/// Принимает ИИ-предложение /// Принимает ИИ-предложение
/// </summary> /// </summary>
/// <param name="containerId">Id контейнера-предложения.</param> /// <param name="containerId">Id контейнера-предложения.</param>
/// <returns>Контейнер после принятия; null — контейнера нет (404).</returns> /// <returns>Контейнер после принятия.</returns>
public Task<ContainerDto?> AcceptSuggestedAsync(string containerId, CancellationToken ct) /// <exception cref="NotFoundException">Контейнер не найден.</exception>
public Task<ContainerDto> AcceptSuggestedAsync(string containerId, CancellationToken ct)
{ {
return PatchAsync(containerId, new ContainerPatchDto( return PatchAsync(containerId, new ContainerPatchDto(
Name: null, Name: null,
@@ -0,0 +1,32 @@
namespace Deal.SharedKernel.Errors;
/// <summary>
/// Коды ошибок Deal для логов и ответов клиенту.
/// </summary>
public static class DealErrorCodes
{
/// <summary>
/// Запрошенный объект не найден.
/// </summary>
public const string NotFound = "not_found";
/// <summary>
/// Некорректные данные запроса.
/// </summary>
public const string Validation = "validation_error";
/// <summary>
/// Конфликт состояния.
/// </summary>
public const string Conflict = "conflict";
/// <summary>
/// Внешний сервис недоступен.
/// </summary>
public const string Unavailable = "unavailable";
/// <summary>
/// Непредвиденная внутренняя ошибка.
/// </summary>
public const string Internal = "internal_error";
}
@@ -0,0 +1,35 @@
using Deal.SharedKernel.Resources;
namespace Deal.SharedKernel.Errors;
/// <summary>
/// База доменных исключений Deal: код ошибки и текст из ресурсов.
/// </summary>
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;
}
/// <summary>
/// Код ошибки для логов и ответов клиенту.
/// </summary>
public string ErrorCode { get; }
}
@@ -0,0 +1,19 @@
using Deal.SharedKernel.Resources;
namespace Deal.SharedKernel.Errors;
/// <summary>
/// Запрошенный объект не найден.
/// </summary>
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)
{
}
}
@@ -0,0 +1,19 @@
using Deal.SharedKernel.Resources;
namespace Deal.SharedKernel.Errors;
/// <summary>
/// Внешний сервис недоступен.
/// </summary>
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)
{
}
}
@@ -0,0 +1,14 @@
using Deal.SharedKernel.Resources;
namespace Deal.SharedKernel.Errors;
/// <summary>
/// Некорректные данные запроса.
/// </summary>
public sealed class ValidationException : DealException
{
public ValidationException(string messageKey, params object?[] messageArgs)
: base(DealErrorCodes.Validation, messageKey, messageArgs)
{
}
}
@@ -0,0 +1,49 @@
namespace Deal.SharedKernel.Resilience;
/// <summary>
/// Повтор операции при транзиентном сбое.
/// </summary>
public static class RetryExecutor
{
/// <summary>
/// Выполняет операцию, повторяя её при транзиентном сбое с задержкой.
/// </summary>
/// <param name="operation">Операция (принимает токен отмены).</param>
/// <param name="retryCount">Число повторов после первой попытки.</param>
/// <param name="baseDelay">Базовая задержка; для повтора N — baseDelay * 2^N.</param>
/// <param name="shouldRetry">Предикат транзиентности сбоя.</param>
/// <param name="delayAsync">Пауза между попытками (в тестах — мгновенная).</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Результат первой успешной попытки.</returns>
public static async Task<TResult> ExecuteAsync<TResult>(
Func<CancellationToken, Task<TResult>> operation,
int retryCount,
TimeSpan baseDelay,
Func<Exception, bool> shouldRetry,
Func<TimeSpan, CancellationToken, Task> delayAsync,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(operation);
ArgumentOutOfRangeException.ThrowIfNegative(retryCount);
ArgumentNullException.ThrowIfNull(shouldRetry);
ArgumentNullException.ThrowIfNull(delayAsync);
for (int attempt = 0; ; attempt++)
{
try
{
return await operation(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (attempt < retryCount
&& shouldRetry(exception)
&& !cancellationToken.IsCancellationRequested)
{
await delayAsync(BackoffDelay(baseDelay, attempt), cancellationToken).ConfigureAwait(false);
}
}
}
// Задержка повтора с экспоненциальным ростом от базовой.
private static TimeSpan BackoffDelay(TimeSpan baseDelay, int attempt)
=> TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt));
}
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="UnexpectedError" xml:space="preserve">
<value>Внутренняя ошибка сервиса. Обратитесь в поддержку.</value>
</data>
<data name="NotFoundEntity" xml:space="preserve">
<value>Объект не найден: {0}.</value>
</data>
<data name="NotFoundEntityWithId" xml:space="preserve">
<value>Объект не найден: {0} (id: {1}).</value>
</data>
<data name="ValidationFailed" xml:space="preserve">
<value>Некорректные данные запроса: {0}.</value>
</data>
<data name="ConflictState" xml:space="preserve">
<value>Конфликт состояния: {0}.</value>
</data>
<data name="ServiceUnavailable" xml:space="preserve">
<value>Сервис «{0}» временно недоступен.</value>
</data>
</root>
@@ -0,0 +1,37 @@
namespace Deal.SharedKernel.Resources;
/// <summary>
/// Ключи текстов ошибок Deal в ресурсах ErrorMessages.resx.
/// </summary>
public static class ErrorResourceKeys
{
/// <summary>
/// Общая непредвиденная внутренняя ошибка.
/// </summary>
public const string UnexpectedError = "UnexpectedError";
/// <summary>
/// Объект не найден (без идентификатора).
/// </summary>
public const string NotFoundEntity = "NotFoundEntity";
/// <summary>
/// Объект не найден (с идентификатором).
/// </summary>
public const string NotFoundEntityWithId = "NotFoundEntityWithId";
/// <summary>
/// Некорректные данные запроса.
/// </summary>
public const string ValidationFailed = "ValidationFailed";
/// <summary>
/// Конфликт состояния.
/// </summary>
public const string ConflictState = "ConflictState";
/// <summary>
/// Внешний сервис недоступен.
/// </summary>
public const string ServiceUnavailable = "ServiceUnavailable";
}
@@ -0,0 +1,34 @@
using System.Globalization;
using System.Resources;
namespace Deal.SharedKernel.Resources;
/// <summary>
/// Тексты ошибок Deal из ресурсов ErrorMessages.resx.
/// </summary>
public static class ErrorResources
{
private const string ResourceBaseName = "Deal.SharedKernel.Resources.ErrorMessages";
private static readonly ResourceManager Manager = new(ResourceBaseName, typeof(ErrorResources).Assembly);
/// <summary>
/// Форматированный текст по ключу ресурса с подстановкой аргументов.
/// </summary>
/// <param name="key">Ключ ресурса (см. <see cref="ErrorResourceKeys"/>).</param>
/// <param name="args">Аргументы шаблона.</param>
/// <returns>Текст ресурса; неизвестный ключ возвращается как есть.</returns>
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);
}
}
@@ -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;
/// <summary>
/// Тесты обработчика необработанных исключений HTTP.
/// </summary>
public sealed class DealExceptionHandlerTests
{
[Fact]
public async Task NotFound_MapsTo404WithCodeAndRussianDetail()
{
DefaultHttpContext context = CreateContext();
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
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<ILogger<DealExceptionHandler>>());
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<ILogger<DealExceptionHandler>>());
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);
}
}
@@ -1,9 +1,10 @@
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -343,13 +344,13 @@ public sealed class CardsServiceTests
} }
[Fact] [Fact]
public async Task Trash_CardMissing_ReturnsNull() public async Task Trash_CardMissing_ThrowsNotFound()
{ {
(CardsService service, _, _, TestMlClient ml) = Create(); (CardsService service, _, _, TestMlClient ml) = Create();
CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.TrashCardAsync("l_ghost", CancellationToken.None));
Assert.Null(result); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(ml.Pushed); Assert.Empty(ml.Pushed);
} }
@@ -425,13 +426,12 @@ public sealed class CardsServiceTests
} }
[Fact] [Fact]
public async Task Restore_CardMissing_ReturnsNull() public async Task Restore_CardMissing_ThrowsNotFound()
{ {
(CardsService service, _, _, _) = Create(); (CardsService service, _, _, _) = Create();
string? back = await service.RestoreCardAsync("l_ghost", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.RestoreCardAsync("l_ghost", CancellationToken.None));
Assert.Null(back); // эндпоинт отвечает 404 «Карточка не найдена»
} }
@@ -4,6 +4,7 @@ using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Abstractions; using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
@@ -91,7 +92,8 @@ public sealed class GrpcMlClientTests
Assert.Null(result.Margin); Assert.Null(result.Margin);
Assert.Empty(result.Terms); Assert.Empty(result.Terms);
Assert.Null(result.Type); Assert.Null(result.Type);
Assert.Single(service.RequestTenantIds); // Недоступность транспорта повторяется — на сервер приходит первая попытка и повторы.
Assert.Equal(GrpcRetry.RetryCount + 1, service.RequestTenantIds.Count);
}); });
} }
@@ -148,7 +150,8 @@ public sealed class GrpcMlClientTests
Assert.False(down.Reachable); Assert.False(down.Reachable);
Assert.False(down.Service.Ready); Assert.False(down.Service.Ready);
Assert.False(down.Stats.Reachable); Assert.False(down.Stats.Reachable);
Assert.Equal(1, service.StatusCalls); // При недоступности транспорта идёт повтор — считаем все попытки.
Assert.Equal(GrpcRetry.RetryCount + 1, service.StatusCalls);
// «Поднялся»: после TTL 15 с следующий StatusAsync обновляет кэш (ready=true, reachable=true). // «Поднялся»: после TTL 15 с следующий StatusAsync обновляет кэш (ready=true, reachable=true).
service.StatusUnavailable = false; service.StatusUnavailable = false;
@@ -165,7 +168,7 @@ public sealed class GrpcMlClientTests
Assert.True(up.Reachable); Assert.True(up.Reachable);
Assert.True(up.Service.Ready); Assert.True(up.Service.Ready);
Assert.Equal(3, up.Service.Learned); Assert.Equal(3, up.Service.Learned);
Assert.Equal(2, service.StatusCalls); Assert.Equal(GrpcRetry.RetryCount + 2, service.StatusCalls);
}); });
} }
@@ -4,6 +4,7 @@ using Deal.Grpc.Ai;
using Deal.Infrastructure.Data; using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
@@ -101,8 +102,9 @@ public sealed class PipelineWorkerGrpcAiTests
Assert.Equal(1, result.AiFail); Assert.Equal(1, result.AiFail);
Assert.Equal(1, result.AiStored); Assert.Equal(1, result.AiStored);
Assert.Single(result.CreatedCards); Assert.Single(result.CreatedCards);
Assert.Equal(1, service.FilterCalls); // Недоступность транспорта повторяется — на сервер приходит первая попытка и повторы.
Assert.Equal(1, service.ClassifyCalls); Assert.Equal(GrpcRetry.RetryCount + 1, service.FilterCalls);
Assert.Equal(GrpcRetry.RetryCount + 1, service.ClassifyCalls);
}); });
} }
@@ -1,8 +1,9 @@
using Deal.Modules.Discovery.Application.Exceptions; using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Discovery.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Discovery; namespace Deal.Tests.Unit.Modules.Discovery;
@@ -131,14 +132,13 @@ public sealed class DiscoveryTasksServiceTests
} }
[Fact] [Fact]
public async Task Patch_MissingTask_ReturnsNull() public async Task Patch_MissingTask_ThrowsNotFound()
{ {
(DiscoveryTasksService service, _, _) = Create(); (DiscoveryTasksService service, _, _) = Create();
DiscoveryTaskDto? patched = await service.PatchAsync( await Assert.ThrowsAsync<NotFoundException>(
"dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None); () => service.PatchAsync(
"dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None));
Assert.Null(patched);
} }
[Fact] [Fact]
@@ -201,13 +201,12 @@ public sealed class DiscoveryTasksServiceTests
} }
[Fact] [Fact]
public async Task Start_MissingTask_ReturnsNull() public async Task Start_MissingTask_ThrowsNotFound()
{ {
(DiscoveryTasksService service, _, _) = Create(); (DiscoveryTasksService service, _, _) = Create();
DiscoveryTaskDto? task = await service.StartAsync("dt_missing", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.StartAsync("dt_missing", CancellationToken.None));
Assert.Null(task);
} }
[Fact] [Fact]
@@ -281,9 +280,8 @@ public sealed class DiscoveryTasksServiceTests
store.SeedCandidate(Candidate("c_2", "dt_2")); store.SeedCandidate(Candidate("c_2", "dt_2"));
await store.Store.UpsertBlacklistAsync("c_1", "Источник", "причина", CancellationToken.None); 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.Single(store.Tasks); // dt_2 осталась
Assert.Equal("dt_2", Assert.Single(store.Tasks).Id); Assert.Equal("dt_2", Assert.Single(store.Tasks).Id);
Assert.Single(store.Candidates); // кандидат dt_2 остался Assert.Single(store.Candidates); // кандидат dt_2 остался
@@ -292,13 +290,12 @@ public sealed class DiscoveryTasksServiceTests
} }
[Fact] [Fact]
public async Task Delete_MissingTask_ReturnsFalse() public async Task Delete_MissingTask_ThrowsNotFound()
{ {
(DiscoveryTasksService service, _, _) = Create(); (DiscoveryTasksService service, _, _) = Create();
bool deleted = await service.DeleteAsync("dt_missing", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.DeleteAsync("dt_missing", CancellationToken.None));
Assert.False(deleted);
} }
[Fact] [Fact]
@@ -1,6 +1,7 @@
using System.Text; using System.Text;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
@@ -73,14 +74,13 @@ public sealed class CardsServiceFilesTests
} }
[Fact] [Fact]
public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject() public async Task Add_CardMissing_ThrowsAndDoesNotWriteObject()
{ {
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create(); (CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
CardFileDto? entry = await service.AddFileAsync( await Assert.ThrowsAsync<NotFoundException>(() => service.AddFileAsync(
"c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); "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(storage.StoredObjectKeys); // «add на несуществующей карточке не пишет объект»
Assert.Empty(store.CardDtos); Assert.Empty(store.CardDtos);
} }
@@ -151,25 +151,23 @@ public sealed class CardsServiceFilesTests
} }
[Fact] [Fact]
public async Task GetEntry_CardMissing_ReturnsNull() public async Task GetEntry_CardMissing_ThrowsNotFound()
{ {
(CardsService service, _, _) = Create(); (CardsService service, _, _) = Create();
CardFileDto? entry = await service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None));
Assert.Null(entry); // 404 «Карточка не найдена» у эндпоинта
} }
[Fact] [Fact]
public async Task GetEntry_FileNotInMetadata_ReturnsNull() public async Task GetEntry_FileNotInMetadata_ThrowsNotFound()
{ {
(CardsService service, TestKanjStore store, _) = Create(); (CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1") store.SeedCard(Card("c_1")
with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } }); with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } });
CardFileDto? entry = await service.GetFileEntryAsync("c_1", "pf_ghost", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.GetFileEntryAsync("c_1", "pf_ghost", CancellationToken.None));
Assert.Null(entry); // файла нет в метаданных карточки — 404-семантика
} }
@@ -225,13 +223,13 @@ public sealed class CardsServiceFilesTests
} }
[Fact] [Fact]
public async Task Remove_CardMissing_ReturnsNullWithoutStorageDelete() public async Task Remove_CardMissing_ThrowsWithoutStorageDelete()
{ {
(CardsService service, _, TestFileStorage storage) = Create(); (CardsService service, _, TestFileStorage storage) = Create();
CardDto? card = await service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None));
Assert.Null(card); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(storage.DeletedKeys); Assert.Empty(storage.DeletedKeys);
} }
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
@@ -99,13 +100,13 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing() public async Task TakeCard_CardMissing_ThrowsAndCreatesNothing()
{ {
(CardsService service, TestKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.TakeCardAsync("c_missing", CancellationToken.None));
Assert.Null(card);
Assert.Empty(store.CardDtos); Assert.Empty(store.CardDtos);
} }
@@ -122,8 +123,7 @@ public sealed class CardsServiceSelectedTests
stack: new[] { "Python", "aiogram" }, stack: new[] { "Python", "aiogram" },
budget: new CardBudgetDto(From: 1600, To: 2200, Cur: "USD"))); budget: new CardBudgetDto(From: 1600, To: 2200, Cur: "USD")));
CardDto card = await service.TakeCardAsync("c_1", CancellationToken.None) CardDto card = await service.TakeCardAsync("c_1", CancellationToken.None);
?? throw new InvalidOperationException("take вернул null при существующей карточке");
Assert.Equal("c_1", card.Id); Assert.Equal("c_1", card.Id);
Assert.Equal("planned", card.Col); Assert.Equal("planned", card.Col);
@@ -182,8 +182,7 @@ public sealed class CardsServiceSelectedTests
("tzText", "ТЗ"), ("tzText", "ТЗ"),
("stack", new[] { "C#", ".NET" }), // стек — полная замена массива ("stack", new[] { "C#", ".NET" }), // стек — полная замена массива
("budget", new { from = 500, cur = "EUR" })), ("budget", new { from = 500, cur = "EUR" })),
CancellationToken.None) CancellationToken.None);
?? throw new InvalidOperationException("patch вернул null при существующей карточке");
Assert.Equal("Новый заголовок", card.Title); Assert.Equal("Новый заголовок", card.Title);
Assert.Equal(string.Empty, card.Summary); // summary очищена пустой строкой Assert.Equal(string.Empty, card.Summary); // summary очищена пустой строкой
@@ -255,16 +254,14 @@ public sealed class CardsServiceSelectedTests
} }
[Fact] [Fact]
public async Task Patch_CardMissing_ReturnsNull() public async Task Patch_CardMissing_ThrowsNotFound()
{ {
(CardsService service, _, _, _) = Create(); (CardsService service, _, _, _) = Create();
CardDto? card = await service.PatchCardAsync( await Assert.ThrowsAsync<NotFoundException>(() => service.PatchCardAsync(
"c_missing", "c_missing",
PatchBody(("title", "Т")), PatchBody(("title", "Т")),
CancellationToken.None); CancellationToken.None));
Assert.Null(card);
} }
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -237,14 +238,12 @@ public sealed class ContainersServiceTests
} }
[Fact] [Fact]
public async Task Patch_UnknownContainer_ReturnsNull() public async Task Patch_UnknownContainer_ThrowsNotFound()
{ {
(ContainersService service, _, _) = Create(); (ContainersService service, _, _) = Create();
ContainerDto? result = await service.PatchAsync( await Assert.ThrowsAsync<NotFoundException>(
"b_missing", Patch(name: "X"), CancellationToken.None); () => service.PatchAsync("b_missing", Patch(name: "X"), CancellationToken.None));
Assert.Null(result); // эндпоинт отвечает 404 «Контейнер не найден»
} }
[Fact] [Fact]
@@ -0,0 +1,34 @@
using Deal.SharedKernel.Resources;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Тесты ресурсов текстов ошибок (ErrorMessages.resx).
/// </summary>
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);
}
}
@@ -0,0 +1,93 @@
using Deal.Infrastructure.Integrations.Resilience;
using Grpc.Core;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Тесты <see cref="GrpcRetry"/> — повтор транзиентных gRPC-сбоев.
/// </summary>
public sealed class GrpcRetryTests
{
[Theory]
[InlineData(StatusCode.Unavailable)]
[InlineData(StatusCode.DeadlineExceeded)]
public void IsTransient_TransportFailures_True(StatusCode statusCode)
{
Assert.True(GrpcRetry.IsTransient(new RpcException(new Status(statusCode, "сбой"))));
}
[Theory]
[InlineData(StatusCode.NotFound)]
[InlineData(StatusCode.InvalidArgument)]
[InlineData(StatusCode.Internal)]
public void IsTransient_ApplicationFailures_False(StatusCode statusCode)
{
Assert.False(GrpcRetry.IsTransient(new RpcException(new Status(statusCode, "сбой"))));
}
[Fact]
public void IsTransient_NonRpcException_False()
{
Assert.False(GrpcRetry.IsTransient(new InvalidOperationException("сбой")));
}
[Fact]
public async Task ExecuteAsync_TransientThenSuccess_Retries()
{
int calls = 0;
string result = await GrpcRetry.ExecuteAsync(
_ =>
{
calls++;
return calls < 2
? Task.FromException<string>(new RpcException(new Status(StatusCode.Unavailable, "down")))
: Task.FromResult("ok");
},
InstantDelayAsync,
CancellationToken.None);
Assert.Equal("ok", result);
Assert.Equal(2, calls);
}
[Fact]
public async Task ExecuteAsync_TransientExhausted_ThrowsRpcException()
{
int calls = 0;
RpcException thrown = await Assert.ThrowsAsync<RpcException>(
() => GrpcRetry.ExecuteAsync(
_ =>
{
calls++;
return Task.FromException<string>(new RpcException(new Status(StatusCode.Unavailable, "down")));
},
InstantDelayAsync,
CancellationToken.None));
Assert.Equal(StatusCode.Unavailable, thrown.StatusCode);
Assert.Equal(GrpcRetry.RetryCount + 1, calls);
}
[Fact]
public async Task ExecuteAsync_ApplicationFailure_NotRetried()
{
int calls = 0;
await Assert.ThrowsAsync<RpcException>(
() => GrpcRetry.ExecuteAsync(
_ =>
{
calls++;
return Task.FromException<string>(new RpcException(new Status(StatusCode.InvalidArgument, "bad")));
},
InstantDelayAsync,
CancellationToken.None));
Assert.Equal(1, calls);
}
private static Task InstantDelayAsync(TimeSpan delay, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
@@ -0,0 +1,158 @@
using Deal.SharedKernel.Resilience;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Тесты <see cref="RetryExecutor"/> — повтор транзиентных сбоев.
/// </summary>
public sealed class RetryExecutorTests
{
private const int RetryCount = 2;
private static readonly TimeSpan BaseDelay = TimeSpan.FromMilliseconds(10);
[Fact]
public async Task FirstAttemptSucceeds_NoRetry()
{
var delays = new List<TimeSpan>();
int calls = 0;
string result = await ExecuteAsync(
() =>
{
calls++;
return Task.FromResult("ok");
},
shouldRetry: _ => true,
delays);
Assert.Equal("ok", result);
Assert.Equal(1, calls);
Assert.Empty(delays);
}
[Fact]
public async Task TransientFailureThenSuccess_RetriesUntilSuccess()
{
var delays = new List<TimeSpan>();
int calls = 0;
string result = await ExecuteAsync(
() =>
{
calls++;
return calls < 3
? throw new InvalidOperationException("транзиент")
: Task.FromResult("ok");
},
shouldRetry: _ => true,
delays);
Assert.Equal("ok", result);
Assert.Equal(3, calls);
Assert.Equal(2, delays.Count);
}
[Fact]
public async Task RetriesExhausted_ThrowsLastFailure()
{
var delays = new List<TimeSpan>();
int calls = 0;
InvalidOperationException thrown = await Assert.ThrowsAsync<InvalidOperationException>(
() => ExecuteAsync<string>(
async () =>
{
calls++;
await Task.Yield();
throw new InvalidOperationException($"сбой {calls}");
},
shouldRetry: _ => true,
delays));
Assert.Equal(3, calls); // первая попытка + 2 повтора
Assert.Equal("сбой 3", thrown.Message);
}
[Fact]
public async Task NonTransientFailure_NotRetried()
{
var delays = new List<TimeSpan>();
int calls = 0;
await Assert.ThrowsAsync<InvalidOperationException>(
() => ExecuteAsync<string>(
async () =>
{
calls++;
await Task.Yield();
throw new InvalidOperationException("не транзиент");
},
shouldRetry: _ => false,
delays));
Assert.Equal(1, calls);
Assert.Empty(delays);
}
[Fact]
public async Task Cancellation_DoesNotRetry()
{
var delays = new List<TimeSpan>();
int calls = 0;
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAsync<InvalidOperationException>(
() => ExecuteAsync<string>(
async () =>
{
calls++;
await Task.Yield();
throw new InvalidOperationException("сбой");
},
shouldRetry: _ => true,
delays,
cts.Token));
Assert.Equal(1, calls);
}
[Fact]
public async Task Backoff_GrowsExponentially()
{
var delays = new List<TimeSpan>();
int calls = 0;
await ExecuteAsync(
() =>
{
calls++;
return calls < 3
? throw new InvalidOperationException("транзиент")
: Task.FromResult(1);
},
shouldRetry: _ => true,
delays);
Assert.Equal(2, delays.Count);
Assert.Equal(BaseDelay, delays[0]);
Assert.Equal(BaseDelay * 2, delays[1]);
}
private static Task<TResult> ExecuteAsync<TResult>(
Func<Task<TResult>> operation,
Func<Exception, bool> shouldRetry,
List<TimeSpan> delays,
CancellationToken cancellationToken = default)
=> RetryExecutor.ExecuteAsync(
_ => operation(),
RetryCount,
BaseDelay,
shouldRetry,
(delay, _) =>
{
delays.Add(delay);
return Task.CompletedTask;
},
cancellationToken);
}
@@ -30,6 +30,11 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Общие доменные исключения и тексты ошибок (Deal.SharedKernel.Errors/Resources). -->
<ProjectReference Include="..\..\core\Deal.SharedKernel\Deal.SharedKernel.csproj" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<!-- gRPC-сервер ASP.NET Core (Interceptor/AddGrpc) и стандартный gRPC-health (Ruling 12). --> <!-- gRPC-сервер ASP.NET Core (Interceptor/AddGrpc) и стандартный gRPC-health (Ruling 12). -->
<PackageReference Include="Grpc.AspNetCore" Version="2.83.0" /> <PackageReference Include="Grpc.AspNetCore" Version="2.83.0" />
@@ -2,6 +2,8 @@ using System.Diagnostics;
using Deal.Grpc.Hosting.Models; using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Options; using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services; using Deal.Grpc.Hosting.Services;
using Deal.SharedKernel.Errors;
using Deal.SharedKernel.Resources;
using Grpc.Core; using Grpc.Core;
using Grpc.Core.Interceptors; using Grpc.Core.Interceptors;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -16,9 +18,6 @@ public sealed class RpcCallLoggingInterceptor : Interceptor
// Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness). // Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness).
private const string HealthMethodPrefix = "/grpc.health.v1.Health/"; private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
// Деталь RpcException для сбоя реализации (фиксированный текст; детали ошибки не наружу).
private const string UnknownFailureDetail = "Внутренняя ошибка сервиса";
private readonly ILogger<RpcCallLoggingInterceptor> _logger; private readonly ILogger<RpcCallLoggingInterceptor> _logger;
/// <summary> /// <summary>
@@ -101,14 +100,46 @@ public sealed class RpcCallLoggingInterceptor : Interceptor
} }
catch (Exception exception) catch (Exception exception)
{ {
// «Прочие» сбои реализации gRPC показал бы клиенту как UNKNOWN мимо access-лога: логируем RpcException mapped = MapFailure(exception, context.Method);
// строку со статусом Unknown, пишем детали сбоя и переводим в RpcException (текст фиксирован). LogCall(context, startedAt, mapped.Status.StatusCode);
_logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", context.Method); throw mapped;
LogCall(context, startedAt, StatusCode.Unknown);
throw new RpcException(new Status(StatusCode.Unknown, UnknownFailureDetail));
} }
} }
// Переводит сбой реализации в 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/дуплексный). // Обёртка для handler-ов, возвращающих Task (server-streaming/дуплексный).
// context: Контекст вызова (метод — context.Method). // context: Контекст вызова (метод — context.Method).
// invoke: Вызов нижестоящего обработчика. // invoke: Вызов нижестоящего обработчика.