diff --git a/docs/spec/Код-стайл-Дейл.md b/docs/spec/Код-стайл-Дейл.md
index 5296b09..c0d6579 100644
--- a/docs/spec/Код-стайл-Дейл.md
+++ b/docs/spec/Код-стайл-Дейл.md
@@ -242,11 +242,23 @@
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
- При пробрасывании выше — `throw;`, а **не** `throw ex;`.
-- Свои исключения наследовать от `Exception`.
+- **Свои доменные исключения наследовать от `DealException`** (`Deal.SharedKernel.Errors`) — базовый тип
+ хранит код ошибки (`ErrorCode`) и умеет брать текст из ресурсов. Состав: `NotFoundException`,
+ `ValidationException`, `ConflictException`, `ServiceUnavailableException`; новые — по тому же образцу.
+- **Не возвращать `null` как штатный результат «не найдено»/ошибки.** Доменный сервис, у которого объект
+ не найден, бросает `NotFoundException` (эндпоинт отдаёт 404 через общий обработчик, а не проверкой
+ `is null` в каждом хендлере). `null` допустим только для **опциональных значений** — парсеры/извлечение
+ полей, выборки-запросы («нет строки» — нормальный результат), `Try*`-паттерн; такие методы должны быть
+ nullable-аннотированы и явно описаны в XML-doc.
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
БД, неизвестные идентификаторы и т.п.).
-- Все исключения должны быть залогированы или показаны пользователю; пустые `catch` запрещены.
-- В лог об ошибке, как правило, писать `StackTrace`.
+- Все исключения должны быть залогированы или показаны пользователю; **пустые `catch` запрещены**.
+- **Единый формат лога ошибки:** понятный русский текст + структурированный контекст (операция, `tenantId`,
+ id сущности, `traceId`). Стектрейс пишется **только в лог**; в ответ/сообщение клиенту он не попадает —
+ наружу отдаётся обобщённый текст и код (обработчики на границах: `DealExceptionHandler`, gRPC-интерцептор).
+- **Тексты исключений/ошибок не хардкодить** — держать в ресурсах (`ErrorMessages.resx`, доступ через
+ `ErrorResources.Format(ErrorResourceKeys.*)` и шаблоны `DealException`), чтобы переводы добавлялись
+ отдельной культурой (`.resx`-спутник) без правок кода.
## 11. Интерфейсы
@@ -259,7 +271,8 @@
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
(напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные
- реализации (codemod `scripts/make_explicit.py`, идемпотентный).
+ реализации (codemod'ы `scripts/make_explicit.py` и `scripts/strip_implementation_docs.py` — идемпотентны,
+ `--apply` применяет правки, без флага — dry-run-отчёт).
- Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа.
- **Маркерные классы не используются** — если нужен маркер, это маркерный интерфейс
(`IKanbanModule`, `ISharedKernel` и т.п.). **[изм. 2026-09-11]**
diff --git a/src/ai-service/Deal.Ai/Llm/LlmHttpClient.cs b/src/ai-service/Deal.Ai/Llm/LlmHttpClient.cs
index 04e3072..9e0b5a2 100644
--- a/src/ai-service/Deal.Ai/Llm/LlmHttpClient.cs
+++ b/src/ai-service/Deal.Ai/Llm/LlmHttpClient.cs
@@ -4,9 +4,6 @@ using System.Text.Json.Nodes;
namespace Deal.Ai.Llm;
-///
-/// HTTP-реализация
-///
public sealed class LlmHttpClient : IProviderClient
{
// Относительный путь OpenAI-совместимого эндпоинта (база уже без хвостового «/»).
@@ -58,13 +55,6 @@ public sealed class LlmHttpClient : IProviderClient
_anthropicCallTimeout = anthropicCallTimeout;
}
- ///
- /// Выполняет один вызов модели по выбранной схеме API.
- ///
- /// Конфиг провайдера (стиль — ApiStyle).
- /// Системный промпт.
- /// Пользовательское сообщение/контекст.
- /// Текст ответа и usage API-ответа (null при его отсутствии).
async Task IProviderClient.ChatAsync(
LlmConfig config,
string systemPrompt,
diff --git a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs
index 0e2054e..42894a2 100644
--- a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs
@@ -136,10 +136,8 @@ public static class CardDetailsEndpoints
}
CardsService service = context.RequestServices.GetRequiredService();
- CardDto? card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
- return card is null
- ? EndpointResults.NotFound(CardNotFoundDetail)
- : await ReadCardAsync(context, card.Id, ct);
+ CardDto card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
+ return await ReadCardAsync(context, card.Id, ct);
}
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
@@ -261,11 +259,7 @@ public static class CardDetailsEndpoints
foreach (IFormFile file in form.Files)
{
await using Stream content = file.OpenReadStream();
- CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
- if (entry is null)
- {
- return EndpointResults.NotFound(CardNotFoundDetail);
- }
+ await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
}
return await ReadCardAsync(context, cardId, ct);
@@ -286,11 +280,7 @@ public static class CardDetailsEndpoints
}
CardsService cardsService = context.RequestServices.GetRequiredService();
- CardFileDto? entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
- if (entry is null)
- {
- return EndpointResults.NotFound(CardNotFoundDetail);
- }
+ CardFileDto entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
if (string.IsNullOrWhiteSpace(entry.ObjectKey))
{
@@ -339,10 +329,8 @@ public static class CardDetailsEndpoints
}
CardsService cardsService = context.RequestServices.GetRequiredService();
- CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct);
- return card is null
- ? EndpointResults.NotFound(CardNotFoundDetail)
- : await ReadCardAsync(context, cardId, ct);
+ await cardsService.RemoveFileAsync(cardId, fileId, ct);
+ return await ReadCardAsync(context, cardId, ct);
}
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
diff --git a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs
index c2f41e1..a000360 100644
--- a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs
@@ -209,11 +209,7 @@ public static class CardsEndpoints
}
CardsService cardsService = context.RequestServices.GetRequiredService();
- CardDto? card = await cardsService.TrashCardAsync(cardId, ct);
- if (card is null)
- {
- return EndpointResults.NotFound(CardNotFoundDetail);
- }
+ await cardsService.TrashCardAsync(cardId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
return Results.Ok(new { ok = true });
@@ -230,11 +226,7 @@ public static class CardsEndpoints
}
CardsService cardsService = context.RequestServices.GetRequiredService();
- string? col = await cardsService.RestoreCardAsync(cardId, ct);
- if (col is null)
- {
- return EndpointResults.NotFound(CardNotFoundDetail);
- }
+ string col = await cardsService.RestoreCardAsync(cardId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
return Results.Ok(new { ok = true, col });
diff --git a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs
index bbab4aa..8e63672 100644
--- a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs
@@ -19,9 +19,6 @@ public static class ContainersEndpoints
// OpenAPI-тег группы.
private const string OpenApiTag = "containers";
- // 404 PATCH/accept: контейнер не найден.
- private const string ContainerNotFoundDetail = "Контейнер не найден";
-
// 400: отсутствующий/явный null name контейнера.
private const string ContainerNameRequiredDetail = "Укажите название колонки";
@@ -143,7 +140,7 @@ public static class ContainersEndpoints
}
ContainersService containers = context.RequestServices.GetRequiredService();
- ContainerDto? updated = await containers.PatchAsync(
+ ContainerDto updated = await containers.PatchAsync(
containerId,
new ContainerPatchDto(
patchBody.Name,
@@ -155,10 +152,6 @@ public static class ContainersEndpoints
NormalizeWireRules(patchBody.Rules),
patchBody.Policy),
ct);
- if (updated is null)
- {
- return EndpointResults.NotFound(ContainerNotFoundDetail);
- }
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
return Results.Ok(new { id = updated.Id });
@@ -176,11 +169,7 @@ public static class ContainersEndpoints
}
ContainersService containers = context.RequestServices.GetRequiredService();
- ContainerDto? accepted = await containers.AcceptSuggestedAsync(containerId, ct);
- if (accepted is null)
- {
- return EndpointResults.NotFound(ContainerNotFoundDetail);
- }
+ ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
return Results.Ok(accepted);
diff --git a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs
index 25a9d39..f46981d 100644
--- a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs
@@ -55,8 +55,7 @@ public static class DiscoveryEndpoints
// Путь лога задачи (GET).
private const string TaskLogPath = "/tasks/{task_id}/log";
- private const string TaskNotFoundDetail = "Задача не найдена";
-
+ // 404: кандидат не найден.
private const string CandidateNotFoundDetail = "Кандидат не найден";
private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
@@ -149,8 +148,8 @@ public static class DiscoveryEndpoints
try
{
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService();
- DiscoveryTaskDto? task = await tasks.PatchAsync(task_id, ToPatch(body), ct);
- return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
+ DiscoveryTaskDto task = await tasks.PatchAsync(task_id, ToPatch(body), ct);
+ return Results.Ok(task);
}
catch (DiscoveryValidationException exception)
{
@@ -169,8 +168,8 @@ public static class DiscoveryEndpoints
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService();
- bool deleted = await tasks.DeleteAsync(task_id, ct);
- return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail);
+ await tasks.DeleteAsync(task_id, ct);
+ return Results.Ok(new { ok = true });
}
private static async Task StartTaskAsync(
@@ -186,8 +185,8 @@ public static class DiscoveryEndpoints
try
{
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService();
- DiscoveryTaskDto? task = await tasks.StartAsync(task_id, ct);
- return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
+ DiscoveryTaskDto task = await tasks.StartAsync(task_id, ct);
+ return Results.Ok(task);
}
catch (DiscoveryValidationException exception)
{
@@ -206,8 +205,8 @@ public static class DiscoveryEndpoints
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService();
- DiscoveryTaskDto? task = await tasks.PauseAsync(task_id, ct);
- return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
+ DiscoveryTaskDto task = await tasks.PauseAsync(task_id, ct);
+ return Results.Ok(task);
}
private static async Task GenerateKeywordsAsync(
@@ -221,11 +220,7 @@ public static class DiscoveryEndpoints
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService();
- DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
- if (task is null)
- {
- return EndpointResults.NotFound(TaskNotFoundDetail);
- }
+ DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
ISettingsStore settings = context.RequestServices.GetRequiredService();
if (!await ReadAiEnabledAsync(settings, ct))
@@ -268,11 +263,7 @@ public static class DiscoveryEndpoints
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService();
- DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
- if (task is null)
- {
- return EndpointResults.NotFound(TaskNotFoundDetail);
- }
+ DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService();
IReadOnlyList items = await candidates.ListAsync(task_id, status, ct);
@@ -403,11 +394,7 @@ public static class DiscoveryEndpoints
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService();
- DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
- if (task is null)
- {
- return EndpointResults.NotFound(TaskNotFoundDetail);
- }
+ DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
DiscoveryLogService log = context.RequestServices.GetRequiredService();
IReadOnlyList items = await log.TaskLogAsync(task_id, ct);
diff --git a/src/core/Deal.Api/Middleware/DealExceptionHandler.cs b/src/core/Deal.Api/Middleware/DealExceptionHandler.cs
new file mode 100644
index 0000000..d119b84
--- /dev/null
+++ b/src/core/Deal.Api/Middleware/DealExceptionHandler.cs
@@ -0,0 +1,83 @@
+using Deal.SharedKernel.Errors;
+using Deal.SharedKernel.Resources;
+using Deal.SharedKernel.Tenants.Abstractions;
+using Microsoft.AspNetCore.Diagnostics;
+
+namespace Deal.Api.Middleware;
+
+public sealed class DealExceptionHandler(ILogger logger) : IExceptionHandler
+{
+ public async ValueTask TryHandleAsync(
+ HttpContext httpContext,
+ Exception exception,
+ CancellationToken cancellationToken)
+ {
+ (int statusCode, string errorCode, string detail) = Resolve(exception);
+ LogFailure(httpContext, exception, statusCode, errorCode);
+ httpContext.Response.StatusCode = statusCode;
+ await httpContext.Response.WriteAsJsonAsync(
+ new { detail, code = errorCode },
+ cancellationToken);
+ return true;
+ }
+
+ // Доменные ошибки отдаются по коду; прочие — обобщённый 500 без деталей и стектрейса.
+ private static (int StatusCode, string ErrorCode, string Detail) Resolve(Exception exception)
+ => exception is DealException dealException
+ ? (MapStatusCode(dealException.ErrorCode), dealException.ErrorCode, dealException.Message)
+ : (StatusCodes.Status500InternalServerError,
+ DealErrorCodes.Internal,
+ ErrorResources.Format(ErrorResourceKeys.UnexpectedError));
+
+ // Код ошибки Deal → статус HTTP.
+ private static int MapStatusCode(string errorCode) => errorCode switch
+ {
+ DealErrorCodes.NotFound => StatusCodes.Status404NotFound,
+ DealErrorCodes.Validation => StatusCodes.Status400BadRequest,
+ DealErrorCodes.Conflict => StatusCodes.Status409Conflict,
+ DealErrorCodes.Unavailable => StatusCodes.Status503ServiceUnavailable,
+ _ => StatusCodes.Status500InternalServerError,
+ };
+
+ // Доменные ошибки — Warning без стектрейса; непредвиденные — Error со стектрейсом (только в лог).
+ private void LogFailure(
+ HttpContext context,
+ Exception exception,
+ int statusCode,
+ string errorCode)
+ {
+ string method = context.Request.Method;
+ string path = context.Request.Path.Value ?? "/";
+ string tenantId = ResolveTenantId(context);
+ if (exception is DealException dealException)
+ {
+ logger.LogWarning(
+ "HTTP {Method} {Path} -> {StatusCode} {ErrorCode}; tenant={TenantId} trace={TraceId}: {Message}",
+ method,
+ path,
+ statusCode,
+ errorCode,
+ tenantId,
+ context.TraceIdentifier,
+ dealException.Message);
+ return;
+ }
+
+ logger.LogError(
+ exception,
+ "HTTP {Method} {Path} -> {StatusCode} {ErrorCode}; tenant={TenantId} trace={TraceId}",
+ method,
+ path,
+ statusCode,
+ errorCode,
+ tenantId,
+ context.TraceIdentifier);
+ }
+
+ // Идентификатор тенанта запроса; вне tenant-запроса — "-".
+ private static string ResolveTenantId(HttpContext context)
+ {
+ ITenantContext? tenantContext = context.RequestServices?.GetService();
+ return tenantContext?.TenantId?.Value ?? "-";
+ }
+}
diff --git a/src/core/Deal.Api/Program.cs b/src/core/Deal.Api/Program.cs
index 38dbbf3..a1eb42e 100644
--- a/src/core/Deal.Api/Program.cs
+++ b/src/core/Deal.Api/Program.cs
@@ -133,6 +133,8 @@ TokenLimitDefaults tenantLimitDefaults = new(
builder.Services.AddDealPersistence(tenantLimitDefaults);
builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
+builder.Services.AddExceptionHandler();
+builder.Services.AddProblemDetails();
MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get() ?? new MlServiceOptions();
builder.Services.AddSingleton(mlOptions);
@@ -326,6 +328,7 @@ if (forwardedHeadersConfig.Enabled)
app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
}
+app.UseExceptionHandler();
app.UseMiddleware();
app.UseCors(corsPolicyName);
diff --git a/src/core/Deal.Infrastructure/Data/TenantContext.cs b/src/core/Deal.Infrastructure/Data/TenantContext.cs
index 65eed6f..ab3520b 100644
--- a/src/core/Deal.Infrastructure/Data/TenantContext.cs
+++ b/src/core/Deal.Infrastructure/Data/TenantContext.cs
@@ -3,22 +3,17 @@ using Deal.SharedKernel.Tenants.Models;
namespace Deal.Infrastructure.Data;
-///
-/// Контекст тенанта на AsyncLocal
-///
public sealed class TenantContext : ITenantContext
{
private static readonly AsyncLocal Current = new();
- public TenantId? TenantId => Current.Value;
+ TenantId? ITenantContext.TenantId => Current.Value;
- public bool HasTenant => Current.Value is not null;
+ bool ITenantContext.HasTenant => Current.Value is not null;
- public string? SchemaName => Current.Value?.SchemaName;
+ string? ITenantContext.SchemaName => Current.Value?.SchemaName;
- ///
void ITenantContext.SetTenant(TenantId tenantId) => Current.Value = tenantId;
- ///
void ITenantContext.Reset() => Current.Value = null;
}
diff --git a/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs b/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs
index 78cf24b..5af4c4f 100644
--- a/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs
@@ -6,9 +6,6 @@ using Deal.Modules.Settings.Application.Models;
namespace Deal.Infrastructure.Integrations.Services;
-///
-/// HTTP-реализация проверки подключения к AI-провайдеру.
-///
public sealed class AiConnectionChecker : IAiConnectionChecker
{
///
@@ -78,7 +75,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
_httpClient = httpClient;
}
- ///
async Task IAiConnectionChecker.CheckAsync(AiCheckRequest request, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
diff --git a/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs b/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs
index c4a7807..bb21ea1 100644
--- a/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs
@@ -9,9 +9,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
-///
-/// Декоратор бюджетного гейта порта
-///
public sealed class BudgetedAiClassifier : IAiClassifier
{
// Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту).
@@ -54,7 +51,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier
_logger = logger;
}
- ///
async Task IAiClassifier.FilterAsync(string text, CancellationToken ct)
{
if (await IsPaidAllowedAsync(ct))
@@ -67,7 +63,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier
return await _localClassifier.FilterAsync(text, ct);
}
- ///
async Task IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
{
if (await IsPaidAllowedAsync(ct))
diff --git a/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs b/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs
index 381775d..6fd005c 100644
--- a/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs
@@ -9,9 +9,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
-///
-/// Декоратор бюджетного гейта порта
-///
public sealed class BudgetedAiTools : IAiTools
{
private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна";
@@ -50,7 +47,6 @@ public sealed class BudgetedAiTools : IAiTools
_logger = logger;
}
- ///
async Task IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
{
BudgetStateDto state = await GateStateAsync(ct);
@@ -69,7 +65,6 @@ public sealed class BudgetedAiTools : IAiTools
Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError);
}
- ///
async Task IAiTools.EvaluateFitAsync(
string text,
string description,
diff --git a/src/core/Deal.Infrastructure/Integrations/Services/CbrRateSource.cs b/src/core/Deal.Infrastructure/Integrations/Services/CbrRateSource.cs
index 3dce510..bd4f630 100644
--- a/src/core/Deal.Infrastructure/Integrations/Services/CbrRateSource.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Services/CbrRateSource.cs
@@ -5,9 +5,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
-///
-/// HTTP-источник курсов ЦБ РФ
-///
public sealed class CbrRateSource : IRatesSource
{
///
@@ -47,7 +44,6 @@ public sealed class CbrRateSource : IRatesSource
_logger = logger;
}
- ///
async Task?> IRatesSource.FetchAsync(CancellationToken ct)
{
try
diff --git a/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiClassifier.cs b/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiClassifier.cs
index c16045c..0207374 100644
--- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiClassifier.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiClassifier.cs
@@ -12,9 +12,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
-///
-/// gRPC-адаптер порта к автономному ai-service.
-///
public sealed class GrpcAiClassifier : IAiClassifier
{
///
@@ -71,7 +68,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
_logger = logger;
}
- ///
async Task IAiClassifier.FilterAsync(string text, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -109,7 +105,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
}
}
- ///
async Task IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
diff --git a/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs b/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs
index 677ba12..6e0a5f0 100644
--- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs
@@ -11,9 +11,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
-///
-/// gRPC-адаптер порта к автономному ai-service.
-///
public sealed class GrpcAiTools : IAiTools
{
///
@@ -71,7 +68,6 @@ public sealed class GrpcAiTools : IAiTools
_logger = logger;
}
- ///
async Task IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -107,7 +103,6 @@ public sealed class GrpcAiTools : IAiTools
}
}
- ///
async Task IAiTools.EvaluateFitAsync(
string text,
string description,
diff --git a/src/core/Deal.Infrastructure/Integrations/Services/GrpcMlClient.cs b/src/core/Deal.Infrastructure/Integrations/Services/GrpcMlClient.cs
index 38ad26d..c709d59 100644
--- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcMlClient.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcMlClient.cs
@@ -17,9 +17,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
-///
-/// gRPC-адаптер порта IMlClient к автономному ml-service.
-///
public sealed class GrpcMlClient : IMlClient, IMlTrainClient
{
///
@@ -98,7 +95,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
_logger = logger;
}
- ///
async Task IMlClient.StatusAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -123,7 +119,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
return new MlStatusResponseDto(Enabled: enabled, Service: snapshot.Service, Reachable: snapshot.Reachable, Stats: stats);
}
- ///
async Task IMlClient.PredictAsync(string text, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -146,7 +141,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
}
}
- ///
async Task IMlClient.ResetAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -174,7 +168,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
return new MlResetResultDto(Ok: true, Error: null);
}
- ///
async Task IMlClient.PushAsync(
string text,
string label,
@@ -184,7 +177,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct);
}
- ///
async Task IMlTrainClient.TrainBatchAsync(IReadOnlyList items, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
diff --git a/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs b/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs
index d7465bf..a51d70c 100644
--- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs
@@ -9,9 +9,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
-///
-/// gRPC-адаптер порта к автономному telegram-service.
-///
public sealed class GrpcTelegramClient : ITelegramGateway
{
///
@@ -58,7 +55,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
_logger = logger;
}
- ///
async Task ITelegramGateway.StatusAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -81,7 +77,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task ITelegramGateway.StartPhoneAsync(
string phone,
int apiId,
@@ -104,7 +99,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task ITelegramGateway.StartQrAsync(
int apiId,
string apiHash,
@@ -128,7 +122,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task ITelegramGateway.SendCodeAsync(string code, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -146,7 +139,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -164,7 +156,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task ITelegramGateway.LogoutAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -180,7 +171,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -197,7 +187,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task ITelegramGateway.SetMonitorAsync(
string dialogId,
bool enabled,
@@ -217,7 +206,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -234,7 +222,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task ITelegramGateway.BackfillAsync(
string dialogId,
bool force,
@@ -255,7 +242,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task> ITelegramGateway.ReadRecentAsync(
string dialogId,
int limit,
@@ -278,7 +264,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task ITelegramGateway.ReadSourceAsync(
string dialogId,
long msgId,
@@ -302,7 +287,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task> ITelegramGateway.SearchAsync(
string query,
int limit,
@@ -323,7 +307,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -349,7 +332,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task ITelegramGateway.ReadForEvalAsync(
string dialogId,
int limit,
@@ -380,7 +362,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task ITelegramGateway.JoinAsync(string username, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -397,7 +378,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
- ///
async Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
diff --git a/src/core/Deal.Infrastructure/Integrations/Services/LocalAiTools.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalAiTools.cs
index a67f332..ca97b26 100644
--- a/src/core/Deal.Infrastructure/Integrations/Services/LocalAiTools.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalAiTools.cs
@@ -3,20 +3,15 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Services;
-///
-/// Локальная реализация без внешнего ИИ-сервиса.
-///
public sealed class LocalAiTools : IAiTools
{
// Сообщение исключения методов (локальный режим = ai-service не подключён).
private const string NotSupportedMessage =
"ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false).";
- ///
Task IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
=> throw new NotSupportedException(NotSupportedMessage);
- ///
Task IAiTools.EvaluateFitAsync(
string text,
string description,
diff --git a/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs
index 1af1518..6dee6a7 100644
--- a/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs
@@ -3,21 +3,16 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Services;
-///
-/// Локальная заглушка без telegram-service.
-///
public sealed class LocalTelegramGateway : ITelegramGateway
{
// Фаза idle-формы (аккаунт не подключён — сервиса нет).
private const string IdlePhase = "idle";
- ///
Task ITelegramGateway.StatusAsync(CancellationToken ct)
{
return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null));
}
- ///
Task ITelegramGateway.StartPhoneAsync(
string phone,
int apiId,
@@ -25,76 +20,61 @@ public sealed class LocalTelegramGateway : ITelegramGateway
CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
- ///
Task ITelegramGateway.StartQrAsync(
int apiId,
string apiHash,
CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
- ///
- public Task SendCodeAsync(string code, CancellationToken ct) => Task.FromResult(IdlePhase);
+ Task ITelegramGateway.SendCodeAsync(string code, CancellationToken ct) => Task.FromResult(IdlePhase);
- ///
- public Task SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
+ Task ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
- ///
Task ITelegramGateway.LogoutAsync(CancellationToken ct) => Task.CompletedTask;
- ///
Task> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
=> Task.FromResult>([]);
- ///
Task ITelegramGateway.SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct) => Task.CompletedTask;
- ///
Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask;
- ///
- public Task BackfillAsync(
+ Task ITelegramGateway.BackfillAsync(
string dialogId,
bool force,
CancellationToken ct) => Task.FromResult(0);
- ///
Task> ITelegramGateway.ReadRecentAsync(
string dialogId,
int limit,
CancellationToken ct)
=> Task.FromResult>([]);
- ///
Task ITelegramGateway.ReadSourceAsync(
string dialogId,
long msgId,
CancellationToken ct)
=> Task.FromResult(new TelegramSourceContentDto(false, null, null));
- ///
Task> ITelegramGateway.SearchAsync(
string query,
int limit,
CancellationToken ct)
=> Task.FromResult>([]);
- ///
Task ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
=> Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false));
- ///
Task ITelegramGateway.ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken ct)
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
- ///
Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
- ///
Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask;
}
diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Services/LocalFileStorage.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Services/LocalFileStorage.cs
index 2619ddb..29e0a86 100644
--- a/src/core/Deal.Infrastructure/Integrations/Storage/Services/LocalFileStorage.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Storage/Services/LocalFileStorage.cs
@@ -3,9 +3,6 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Storage.Services;
-///
-/// Локальное файловое хранилище вложений — каталог на диске.
-///
public sealed class LocalFileStorage : IFileStorage
{
// Размер буфера чтения при скачивании (async FileStream).
@@ -31,7 +28,6 @@ public sealed class LocalFileStorage : IFileStorage
/// Строка вида LocalFileStorage (root: …).
public override string ToString() => $"LocalFileStorage (root: {_rootPath})";
- ///
async Task IFileStorage.PutAsync(
string objectKey,
Stream content,
@@ -55,7 +51,6 @@ public sealed class LocalFileStorage : IFileStorage
return objectKey;
}
- ///
Task IFileStorage.GetAsync(string objectKey, CancellationToken ct)
{
string path = ResolvePath(objectKey);
@@ -68,7 +63,6 @@ public sealed class LocalFileStorage : IFileStorage
return Task.FromResult(stream);
}
- ///
Task IFileStorage.StatAsync(string objectKey, CancellationToken ct)
{
string path = ResolvePath(objectKey);
@@ -81,7 +75,6 @@ public sealed class LocalFileStorage : IFileStorage
return Task.FromResult(new FileMeta(objectKey, info.Length, string.Empty));
}
- ///
Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
{
string path = ResolvePath(objectKey);
diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs
index 8e47439..e9df842 100644
--- a/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs
@@ -9,9 +9,6 @@ using Minio.Exceptions;
namespace Deal.Infrastructure.Integrations.Storage.Services;
-///
-/// Хранилище вложений на MinIO
-///
public sealed class MinioFileStorage : IFileStorage
{
private const string DefaultContentType = "application/octet-stream";
@@ -66,7 +63,6 @@ public sealed class MinioFileStorage : IFileStorage
/// Строка вида MinioFileStorage (endpoint: …; bucket: …).
public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})";
- ///
async Task IFileStorage.PutAsync(
string objectKey,
Stream content,
@@ -98,7 +94,6 @@ public sealed class MinioFileStorage : IFileStorage
return objectKey;
}
- ///
async Task IFileStorage.GetAsync(string objectKey, CancellationToken ct)
{
MemoryStream buffer = new();
@@ -128,7 +123,6 @@ public sealed class MinioFileStorage : IFileStorage
return buffer;
}
- ///
async Task IFileStorage.StatAsync(string objectKey, CancellationToken ct)
{
try
@@ -144,7 +138,6 @@ public sealed class MinioFileStorage : IFileStorage
}
}
- ///
async Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
{
try
diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs
index d1e3e13..23305ee 100644
--- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs
+++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs
@@ -5,9 +5,6 @@ using Deal.Modules.Discovery.Application.Models;
namespace Deal.Infrastructure.Persistence.Repositories;
-///
-/// EF-адаптер хранилища Discovery
-///
public sealed partial class DiscoveryStore : IDiscoveryStore
{
private readonly TenantDbContext _dbContext;
diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs
index 5bce02b..e48a5f0 100644
--- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs
+++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs
@@ -9,9 +9,6 @@ using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
-///
-/// EF-адаптер хранилища карточек и контейнеров
-///
public sealed partial class KanbanStore : ICardStore
{
private readonly TenantDbContext _dbContext;
diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs
index 368b0e4..7918de9 100644
--- a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs
+++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs
@@ -6,9 +6,6 @@ using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
-///
-/// EF-адаптер хранилища лимитов ИИ-бюджета
-///
public sealed class TenantLimitStore : ITenantLimitStore
{
private readonly DealDbContext _dbContext;
@@ -58,7 +55,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
_utcNow = utcNow;
}
- ///
async Task ITenantLimitStore.GetOrCreateAsync(
Guid tenantId,
CancellationToken ct,
@@ -68,7 +64,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return ToLimitDto(entity);
}
- ///
async Task ITenantLimitStore.GetStateAsync(Guid tenantId, CancellationToken ct)
{
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
@@ -76,7 +71,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return await ToStateDtoAsync(entity, ct);
}
- ///
async Task ITenantLimitStore.AddUsageAsync(
Guid tenantId,
long tokens,
@@ -109,7 +103,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return await ToStateDtoAsync(entity, ct);
}
- ///
async Task ITenantLimitStore.UpdateBudgetAsync(
Guid tenantId,
long budgetTokens,
@@ -132,7 +125,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return await ToStateDtoAsync(entity, ct);
}
- ///
async Task ITenantLimitStore.TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
{
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
@@ -148,7 +140,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return true;
}
- ///
async Task ITenantLimitStore.TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
{
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
@@ -219,7 +210,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return true;
}
- ///
async Task ITenantLimitStore.ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
{
// Трогаем только строки с накоплениями (расход/флаги): строки без накоплений чистить нечего.
diff --git a/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs b/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs
index 979b214..7e036a6 100644
--- a/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs
+++ b/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs
@@ -4,9 +4,6 @@ using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Infrastructure.Security;
-///
-/// AES-256-GCM-шифр секретов
-///
public sealed class AesGcmSecretCipher : ISecretCipher
{
// Префикс зашифрованного значения (маркер формата в хранилище).
@@ -39,7 +36,6 @@ public sealed class AesGcmSecretCipher : ISecretCipher
_key = key;
}
- ///
string ISecretCipher.Encrypt(string plainText)
{
if (string.IsNullOrEmpty(plainText))
@@ -65,7 +61,6 @@ public sealed class AesGcmSecretCipher : ISecretCipher
return EncryptedPrefix + Convert.ToBase64String(payload);
}
- ///
string ISecretCipher.Decrypt(string cipherText)
{
if (string.IsNullOrEmpty(cipherText) || !cipherText.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoverySearchErrorCounter.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoverySearchErrorCounter.cs
index 98d1b42..cb1aa66 100644
--- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoverySearchErrorCounter.cs
+++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoverySearchErrorCounter.cs
@@ -3,9 +3,6 @@ using Deal.Modules.Discovery.Application.Abstractions;
namespace Deal.Modules.Discovery.Application.Services;
-///
-/// Потокобезопасная реализация
-///
public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
{
///
@@ -43,7 +40,6 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
_utcNow = utcNow;
}
- ///
int IDiscoverySearchErrorCounter.Next(string taskId)
{
EvictExpired();
@@ -55,7 +51,6 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
return fresh.Count;
}
- ///
void IDiscoverySearchErrorCounter.Reset(string taskId)
{
EvictExpired();
diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs
index 3a6c515..831b150 100644
--- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs
+++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs
@@ -4,6 +4,7 @@ using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
+using Deal.SharedKernel.Errors;
namespace Deal.Modules.Discovery.Application.Services;
@@ -12,6 +13,9 @@ namespace Deal.Modules.Discovery.Application.Services;
///
public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings)
{
+ // Имя сущности для текста ошибки «не найдено».
+ private const string TaskEntityName = "Задача поиска";
+
///
/// 400 create: пустое название после Trim.
///
@@ -35,10 +39,12 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
/// Одна задача по id.
///
/// Id задачи (dt_...).
- /// Задача или null (404 «Задача не найдена» у эндпоинта).
- public Task GetAsync(string taskId, CancellationToken ct)
+ /// Задача.
+ /// Задача не найдена.
+ public async Task GetAsync(string taskId, CancellationToken ct)
{
- return store.GetTaskAsync(taskId, ct);
+ return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
+ ?? throw new NotFoundException(TaskEntityName, taskId);
}
///
@@ -102,18 +108,16 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
///
/// Id задачи (dt_...).
/// Изменяемые поля (null — не меняется).
- /// Обновлённая задача либо null (404 «Задача не найдена»).
+ /// Обновлённая задача.
+ /// Задача не найдена.
/// Новый план вне границ / бюджет исчерпан.
- public async Task PatchAsync(
+ public async Task PatchAsync(
string taskId,
DiscoveryTaskPatch patch,
CancellationToken ct)
{
- DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
- if (current is null)
- {
- return null;
- }
+ DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
+ ?? throw new NotFoundException(TaskEntityName, taskId);
DiscoveryTaskPatch normalized = NormalizeTaskPatch(patch);
if (normalized.PlanJoins is int newPlan)
@@ -131,38 +135,34 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
}
await store.PatchTaskAsync(taskId, normalized, ct).ConfigureAwait(false);
- return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
+ return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
+ ?? throw new NotFoundException(TaskEntityName, taskId);
}
///
/// Удаляет задачу вместе с кандидатами и логом.
///
/// Id задачи (dt_...).
- /// True — задача удалена; false — строки нет (404 у эндпоинта).
- public async Task DeleteAsync(string taskId, CancellationToken ct)
+ /// Задача не найдена.
+ public async Task DeleteAsync(string taskId, CancellationToken ct)
{
- DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
- if (current is null)
- {
- return false;
- }
+ DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
+ ?? throw new NotFoundException(TaskEntityName, taskId);
- return await store.DeleteTaskAsync(taskId, ct).ConfigureAwait(false);
+ await store.DeleteTaskAsync(current.Id, ct).ConfigureAwait(false);
}
///
/// Запускает поиск
///
/// Id задачи (dt_...).
- /// Задача в running либо null (404).
+ /// Задача в running.
+ /// Задача не найдена.
/// Ключевых слов нет.
- public async Task StartAsync(string taskId, CancellationToken ct)
+ public async Task StartAsync(string taskId, CancellationToken ct)
{
- DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
- if (current is null)
- {
- return null;
- }
+ DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
+ ?? throw new NotFoundException(TaskEntityName, taskId);
if (current.Keywords.Count == 0)
{
@@ -171,24 +171,24 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
bool resetProgress = DiscoveryTaskStatuses.IsFinished(current.Status);
await store.SetTaskRunningAsync(taskId, resetProgress, ct).ConfigureAwait(false);
- return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
+ return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
+ ?? throw new NotFoundException(TaskEntityName, taskId);
}
///
/// Ставит задачу на паузу.
///
/// Id задачи (dt_...).
- /// Задача в paused либо null (404).
- public async Task PauseAsync(string taskId, CancellationToken ct)
+ /// Задача в paused.
+ /// Задача не найдена.
+ public async Task PauseAsync(string taskId, CancellationToken ct)
{
- DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
- if (current is null)
- {
- return null;
- }
+ DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
+ ?? throw new NotFoundException(TaskEntityName, taskId);
- await store.SetTaskPausedAsync(taskId, ct).ConfigureAwait(false);
- return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
+ await store.SetTaskPausedAsync(current.Id, ct).ConfigureAwait(false);
+ return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
+ ?? throw new NotFoundException(TaskEntityName, taskId);
}
///
diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs
index 33e0ee1..f0bfc89 100644
--- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs
+++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs
@@ -1,5 +1,6 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Modules.Kanban.Application.Models;
+using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services;
@@ -18,6 +19,10 @@ public sealed partial class CardsService
private const string DefaultAttachmentName = "file";
+ // Имена сущностей для текстов ошибок «не найдено».
+ private const string CardEntityName = "Карточка";
+ private const string CardFileEntityName = "Файл карточки";
+
///
/// Добавляет файл карточке
///
@@ -26,8 +31,9 @@ public sealed partial class CardsService
/// MIME-тип загрузки (может быть null/пустым — детект по расширению).
/// Поток содержимого файла (читается хранилищем с позиции 0).
/// Длина содержимого в байтах (пишется в метаданные записи).
- /// Метаданные добавленного файла или null — карточки нет (404).
- public async Task AddFileAsync(
+ /// Метаданные добавленного файла.
+ /// Карточка не найдена.
+ public async Task AddFileAsync(
string cardId,
string fileName,
string? contentType,
@@ -37,11 +43,8 @@ public sealed partial class CardsService
{
ArgumentNullException.ThrowIfNull(content);
- CardDto? card = await _store.GetCardAsync(cardId, ct);
- if (card is null)
- {
- return null;
- }
+ CardDto card = await _store.GetCardAsync(cardId, ct)
+ ?? throw new NotFoundException(CardEntityName, cardId);
string name = string.IsNullOrWhiteSpace(fileName) ? DefaultAttachmentName : fileName;
CardFileKind kind = FileKindDetector.Detect(name, contentType);
@@ -62,7 +65,7 @@ public sealed partial class CardsService
// Карточка исчезла между чтением и записью (гонка): объект-сирота в хранилище не нужен —
// удаляем и отвечаем 404-семантикой (DeleteAsync сбои не бросает).
await _storage.DeleteAsync(objectKey, ct);
- return null;
+ throw new NotFoundException(CardEntityName, cardId);
}
return entry;
@@ -73,19 +76,18 @@ public sealed partial class CardsService
///
/// Id карточки (c_...).
/// Id записи файла (pf_...).
- /// Метаданные записи файла либо null (карточка/запись не найдены).
- public async Task GetFileEntryAsync(
+ /// Метаданные записи файла.
+ /// Карточка или запись файла не найдены.
+ public async Task GetFileEntryAsync(
string cardId,
string fileId,
CancellationToken ct)
{
- CardDto? card = await _store.GetCardAsync(cardId, ct);
- if (card is null)
- {
- return null;
- }
+ CardDto card = await _store.GetCardAsync(cardId, ct)
+ ?? throw new NotFoundException(CardEntityName, cardId);
- return card.Files.FirstOrDefault(file => file.Id == fileId);
+ return card.Files.FirstOrDefault(file => file.Id == fileId)
+ ?? throw new NotFoundException(CardFileEntityName, fileId);
}
///
@@ -93,17 +95,15 @@ public sealed partial class CardsService
///
/// Id карточки (c_...).
/// Id удаляемой записи файла (pf_...).
- /// Карточка после удаления (без записи) либо null — карточки нет (404-семантика).
- public async Task RemoveFileAsync(
+ /// Карточка после удаления (без записи).
+ /// Карточка или запись файла не найдены.
+ public async Task RemoveFileAsync(
string cardId,
string fileId,
CancellationToken ct)
{
- CardDto? card = await _store.GetCardAsync(cardId, ct);
- if (card is null)
- {
- return null;
- }
+ CardDto card = await _store.GetCardAsync(cardId, ct)
+ ?? throw new NotFoundException(CardEntityName, cardId);
CardFileDto? entry = card.Files.FirstOrDefault(file => file.Id == fileId);
if (entry is not null && !string.IsNullOrWhiteSpace(entry.ObjectKey))
@@ -113,11 +113,11 @@ public sealed partial class CardsService
if (!await _store.RemoveFileAsync(cardId, fileId, ct))
{
- return null;
+ throw new NotFoundException(CardFileEntityName, fileId);
}
return await _store.GetCardAsync(cardId, ct)
- ?? throw new InvalidOperationException("Карточка не прочиталась после удаления файла: " + cardId);
+ ?? throw new NotFoundException(CardEntityName, cardId);
}
private static string BuildObjectKey(
diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs
index df3471a..1378c99 100644
--- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs
+++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs
@@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
+using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services;
@@ -87,8 +88,9 @@ public sealed partial class CardsService
/// Перенос карточки в корзину
///
/// Id карточки (c_...).
- /// Карточка после переноса (при no-op — как была) либо null — карточки нет (404).
- public Task TrashCardAsync(string cardId, CancellationToken ct)
+ /// Карточка после переноса (при no-op — как была).
+ /// Карточка не найдена.
+ public Task TrashCardAsync(string cardId, CancellationToken ct)
{
return TrashCardAsync(cardId, teach: true, ct);
}
@@ -98,17 +100,15 @@ public sealed partial class CardsService
///
/// Id карточки (c_...).
/// True — писать сигнал «спам» (действие пользователя); false — не писать.
- /// Карточка после переноса (при no-op — как была) либо null — карточки нет (404).
- public async Task TrashCardAsync(
+ /// Карточка после переноса (при no-op — как была).
+ /// Карточка не найдена.
+ public async Task TrashCardAsync(
string cardId,
bool teach,
CancellationToken ct)
{
- CardDto? card = await _store.GetCardAsync(cardId, ct);
- if (card is null)
- {
- return null;
- }
+ CardDto card = await _store.GetCardAsync(cardId, ct)
+ ?? throw new NotFoundException(CardEntityName, cardId);
if (card.Col == CardIds.Trash)
{
@@ -122,21 +122,20 @@ public sealed partial class CardsService
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct);
}
- return await _store.GetCardAsync(cardId, ct);
+ return await _store.GetCardAsync(cardId, ct)
+ ?? throw new NotFoundException(CardEntityName, cardId);
}
///
/// Возврат карточки из архива/корзины на канбан.
///
/// Id карточки (c_...).
- /// Колонка возврата (inbox/доска) либо null — карточки нет (404).
- public async Task RestoreCardAsync(string cardId, CancellationToken ct)
+ /// Колонка возврата (inbox/доска).
+ /// Карточка не найдена.
+ public async Task RestoreCardAsync(string cardId, CancellationToken ct)
{
- CardDto? card = await _store.GetCardAsync(cardId, ct);
- if (card is null)
- {
- return null;
- }
+ CardDto card = await _store.GetCardAsync(cardId, ct)
+ ?? throw new NotFoundException(CardEntityName, cardId);
string back = await ResolveReturnColAsync(card.PrevCol, ct);
string text = LearningText(card);
diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs
index bce0780..69899fd 100644
--- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs
+++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs
@@ -3,6 +3,7 @@ using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
+using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services;
@@ -101,14 +102,12 @@ public sealed partial class CardsService
/// «Взять в работу»
///
/// Id карточки (c_...).
- /// Карточка в стадии planned; null — карточки нет (404).
- public async Task TakeCardAsync(string cardId, CancellationToken ct)
+ /// Карточка в стадии planned.
+ /// Карточка не найдена.
+ public async Task TakeCardAsync(string cardId, CancellationToken ct)
{
- CardDto? card = await _store.GetCardAsync(cardId, ct);
- if (card is null)
- {
- return null;
- }
+ CardDto card = await _store.GetCardAsync(cardId, ct)
+ ?? throw new NotFoundException(CardEntityName, cardId);
if (CardsDefaultContainers.Contains(card.Col))
{
@@ -120,14 +119,14 @@ public sealed partial class CardsService
if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct))
{
// Карточка исчезла между чтением и переносом (гонка с удалением).
- return null;
+ throw new NotFoundException(CardEntityName, cardId);
}
await _store.AddCommentAsync(
PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct);
return await _store.GetCardAsync(cardId, ct)
- ?? throw new InvalidOperationException("Карточка не прочиталась после take: " + cardId);
+ ?? throw new NotFoundException(CardEntityName, cardId);
}
///
@@ -135,8 +134,9 @@ public sealed partial class CardsService
///
/// Id карточки (c_...).
/// Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).
- /// Обновлённая карточка или null — карточки нет (404).
- public async Task PatchCardAsync(
+ /// Обновлённая карточка.
+ /// Карточка не найдена.
+ public async Task PatchCardAsync(
string cardId,
IReadOnlyDictionary body,
CancellationToken ct)
@@ -144,7 +144,13 @@ public sealed partial class CardsService
ArgumentNullException.ThrowIfNull(body);
bool updated = await _store.PatchCardAsync(cardId, ResolvePatch(body), ct);
- return updated ? await _store.GetCardAsync(cardId, ct) : null;
+ if (!updated)
+ {
+ throw new NotFoundException(CardEntityName, cardId);
+ }
+
+ return await _store.GetCardAsync(cardId, ct)
+ ?? throw new NotFoundException(CardEntityName, cardId);
}
///
diff --git a/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs b/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs
index a1168a8..6d75d61 100644
--- a/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs
+++ b/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs
@@ -4,6 +4,7 @@ using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
+using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services;
@@ -19,6 +20,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
///
public const string DefaultContainerName = "Новая колонка";
+ // Имя сущности для текста ошибки «не найдено».
+ private const string ContainerEntityName = "Контейнер";
+
// Палитра колонок по умолчанию: цвет = Palette[order % 8], если цвет не задан.
private static readonly string[] Palette =
["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"];
@@ -53,17 +57,15 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
}
///
- /// Один контейнер со счётчиками; null — контейнера нет.
+ /// Один контейнер со счётчиками
///
/// Id контейнера.
- /// Контейнер со счётчиками либо null.
- public async Task GetAsync(string containerId, CancellationToken ct)
+ /// Контейнер со счётчиками.
+ /// Контейнер не найден.
+ public async Task GetAsync(string containerId, CancellationToken ct)
{
- ContainerDto? container = await store.GetContainerAsync(containerId, ct);
- if (container is null)
- {
- return null;
- }
+ ContainerDto container = await store.GetContainerAsync(containerId, ct)
+ ?? throw new NotFoundException(ContainerEntityName, containerId);
IReadOnlyDictionary counts = await store.CountCardsByColAsync(ct);
ContainerCountsDto containerCounts = counts.TryGetValue(container.Id, out CardColumnCountDto? count)
@@ -107,17 +109,15 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
///
/// Id контейнера.
/// Изменения; null-поле означает «не менять».
- /// Контейнер после патча; null — контейнера нет (404 «Контейнер не найден»).
- public async Task PatchAsync(
+ /// Контейнер после патча.
+ /// Контейнер не найден.
+ public async Task PatchAsync(
string containerId,
ContainerPatchDto patch,
CancellationToken ct)
{
- ContainerDto? current = await store.GetContainerAsync(containerId, ct);
- if (current is null)
- {
- return null;
- }
+ ContainerDto current = await store.GetContainerAsync(containerId, ct)
+ ?? throw new NotFoundException(ContainerEntityName, containerId);
ContainerDto updated = ApplyPatch(current, patch);
await store.UpdateContainerAsync(updated, ct);
@@ -128,8 +128,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
/// Принимает ИИ-предложение
///
/// Id контейнера-предложения.
- /// Контейнер после принятия; null — контейнера нет (404).
- public Task AcceptSuggestedAsync(string containerId, CancellationToken ct)
+ /// Контейнер после принятия.
+ /// Контейнер не найден.
+ public Task AcceptSuggestedAsync(string containerId, CancellationToken ct)
{
return PatchAsync(containerId, new ContainerPatchDto(
Name: null,
diff --git a/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs b/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs
index ba7e7c3..1876f4c 100644
--- a/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs
+++ b/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs
@@ -3,14 +3,9 @@ using Isopoh.Cryptography.Argon2;
namespace Deal.Modules.Tenants.Application.Services;
-///
-/// Реализация на Argon2id.
-///
public sealed class DefaultPasswordHasher : IPasswordHasher
{
- ///
- public string Hash(string password) => Argon2.Hash(password);
+ string IPasswordHasher.Hash(string password) => Argon2.Hash(password);
- ///
- public bool Verify(string password, string encodedHash) => Argon2.Verify(encodedHash, password);
+ bool IPasswordHasher.Verify(string password, string encodedHash) => Argon2.Verify(encodedHash, password);
}
diff --git a/src/core/Deal.SharedKernel/Errors/DealErrorCodes.cs b/src/core/Deal.SharedKernel/Errors/DealErrorCodes.cs
new file mode 100644
index 0000000..85b73ba
--- /dev/null
+++ b/src/core/Deal.SharedKernel/Errors/DealErrorCodes.cs
@@ -0,0 +1,32 @@
+namespace Deal.SharedKernel.Errors;
+
+///
+/// Коды ошибок Deal для логов и ответов клиенту.
+///
+public static class DealErrorCodes
+{
+ ///
+ /// Запрошенный объект не найден.
+ ///
+ public const string NotFound = "not_found";
+
+ ///
+ /// Некорректные данные запроса.
+ ///
+ public const string Validation = "validation_error";
+
+ ///
+ /// Конфликт состояния.
+ ///
+ public const string Conflict = "conflict";
+
+ ///
+ /// Внешний сервис недоступен.
+ ///
+ public const string Unavailable = "unavailable";
+
+ ///
+ /// Непредвиденная внутренняя ошибка.
+ ///
+ public const string Internal = "internal_error";
+}
diff --git a/src/core/Deal.SharedKernel/Errors/DealException.cs b/src/core/Deal.SharedKernel/Errors/DealException.cs
new file mode 100644
index 0000000..3716abf
--- /dev/null
+++ b/src/core/Deal.SharedKernel/Errors/DealException.cs
@@ -0,0 +1,35 @@
+using Deal.SharedKernel.Resources;
+
+namespace Deal.SharedKernel.Errors;
+
+///
+/// База доменных исключений Deal: код ошибки и текст из ресурсов.
+///
+public abstract class DealException : Exception
+{
+ protected DealException(
+ string errorCode,
+ string messageKey,
+ params object?[] messageArgs)
+ : base(ErrorResources.Format(messageKey, messageArgs))
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(errorCode);
+ ErrorCode = errorCode;
+ }
+
+ protected DealException(
+ string errorCode,
+ Exception innerException,
+ string messageKey,
+ params object?[] messageArgs)
+ : base(ErrorResources.Format(messageKey, messageArgs), innerException)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(errorCode);
+ ErrorCode = errorCode;
+ }
+
+ ///
+ /// Код ошибки для логов и ответов клиенту.
+ ///
+ public string ErrorCode { get; }
+}
diff --git a/src/core/Deal.SharedKernel/Errors/NotFoundException.cs b/src/core/Deal.SharedKernel/Errors/NotFoundException.cs
new file mode 100644
index 0000000..d784335
--- /dev/null
+++ b/src/core/Deal.SharedKernel/Errors/NotFoundException.cs
@@ -0,0 +1,19 @@
+using Deal.SharedKernel.Resources;
+
+namespace Deal.SharedKernel.Errors;
+
+///
+/// Запрошенный объект не найден.
+///
+public sealed class NotFoundException : DealException
+{
+ public NotFoundException(string entityName)
+ : base(DealErrorCodes.NotFound, ErrorResourceKeys.NotFoundEntity, entityName)
+ {
+ }
+
+ public NotFoundException(string entityName, string entityId)
+ : base(DealErrorCodes.NotFound, ErrorResourceKeys.NotFoundEntityWithId, entityName, entityId)
+ {
+ }
+}
diff --git a/src/core/Deal.SharedKernel/Errors/ServiceUnavailableException.cs b/src/core/Deal.SharedKernel/Errors/ServiceUnavailableException.cs
new file mode 100644
index 0000000..97b3284
--- /dev/null
+++ b/src/core/Deal.SharedKernel/Errors/ServiceUnavailableException.cs
@@ -0,0 +1,19 @@
+using Deal.SharedKernel.Resources;
+
+namespace Deal.SharedKernel.Errors;
+
+///
+/// Внешний сервис недоступен.
+///
+public sealed class ServiceUnavailableException : DealException
+{
+ public ServiceUnavailableException(string serviceName)
+ : base(DealErrorCodes.Unavailable, ErrorResourceKeys.ServiceUnavailable, serviceName)
+ {
+ }
+
+ public ServiceUnavailableException(string serviceName, Exception innerException)
+ : base(DealErrorCodes.Unavailable, innerException, ErrorResourceKeys.ServiceUnavailable, serviceName)
+ {
+ }
+}
diff --git a/src/core/Deal.SharedKernel/Errors/ValidationException.cs b/src/core/Deal.SharedKernel/Errors/ValidationException.cs
new file mode 100644
index 0000000..375677b
--- /dev/null
+++ b/src/core/Deal.SharedKernel/Errors/ValidationException.cs
@@ -0,0 +1,14 @@
+using Deal.SharedKernel.Resources;
+
+namespace Deal.SharedKernel.Errors;
+
+///
+/// Некорректные данные запроса.
+///
+public sealed class ValidationException : DealException
+{
+ public ValidationException(string messageKey, params object?[] messageArgs)
+ : base(DealErrorCodes.Validation, messageKey, messageArgs)
+ {
+ }
+}
diff --git a/src/core/Deal.SharedKernel/Resources/ErrorMessages.resx b/src/core/Deal.SharedKernel/Resources/ErrorMessages.resx
new file mode 100644
index 0000000..225eed2
--- /dev/null
+++ b/src/core/Deal.SharedKernel/Resources/ErrorMessages.resx
@@ -0,0 +1,33 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Внутренняя ошибка сервиса. Обратитесь в поддержку.
+
+
+ Объект не найден: {0}.
+
+
+ Объект не найден: {0} (id: {1}).
+
+
+ Некорректные данные запроса: {0}.
+
+
+ Конфликт состояния: {0}.
+
+
+ Сервис «{0}» временно недоступен.
+
+
diff --git a/src/core/Deal.SharedKernel/Resources/ErrorResourceKeys.cs b/src/core/Deal.SharedKernel/Resources/ErrorResourceKeys.cs
new file mode 100644
index 0000000..258c3fa
--- /dev/null
+++ b/src/core/Deal.SharedKernel/Resources/ErrorResourceKeys.cs
@@ -0,0 +1,37 @@
+namespace Deal.SharedKernel.Resources;
+
+///
+/// Ключи текстов ошибок Deal в ресурсах ErrorMessages.resx.
+///
+public static class ErrorResourceKeys
+{
+ ///
+ /// Общая непредвиденная внутренняя ошибка.
+ ///
+ public const string UnexpectedError = "UnexpectedError";
+
+ ///
+ /// Объект не найден (без идентификатора).
+ ///
+ public const string NotFoundEntity = "NotFoundEntity";
+
+ ///
+ /// Объект не найден (с идентификатором).
+ ///
+ public const string NotFoundEntityWithId = "NotFoundEntityWithId";
+
+ ///
+ /// Некорректные данные запроса.
+ ///
+ public const string ValidationFailed = "ValidationFailed";
+
+ ///
+ /// Конфликт состояния.
+ ///
+ public const string ConflictState = "ConflictState";
+
+ ///
+ /// Внешний сервис недоступен.
+ ///
+ public const string ServiceUnavailable = "ServiceUnavailable";
+}
diff --git a/src/core/Deal.SharedKernel/Resources/ErrorResources.cs b/src/core/Deal.SharedKernel/Resources/ErrorResources.cs
new file mode 100644
index 0000000..0713550
--- /dev/null
+++ b/src/core/Deal.SharedKernel/Resources/ErrorResources.cs
@@ -0,0 +1,34 @@
+using System.Globalization;
+using System.Resources;
+
+namespace Deal.SharedKernel.Resources;
+
+///
+/// Тексты ошибок Deal из ресурсов ErrorMessages.resx.
+///
+public static class ErrorResources
+{
+ private const string ResourceBaseName = "Deal.SharedKernel.Resources.ErrorMessages";
+
+ private static readonly ResourceManager Manager = new(ResourceBaseName, typeof(ErrorResources).Assembly);
+
+ ///
+ /// Форматированный текст по ключу ресурса с подстановкой аргументов.
+ ///
+ /// Ключ ресурса (см. ).
+ /// Аргументы шаблона.
+ /// Текст ресурса; неизвестный ключ возвращается как есть.
+ public static string Format(string key, params object?[] args)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(key);
+ string? template = Manager.GetString(key, CultureInfo.CurrentUICulture);
+ if (string.IsNullOrEmpty(template))
+ {
+ return key;
+ }
+
+ return args.Length == 0
+ ? template
+ : string.Format(CultureInfo.CurrentUICulture, template, args);
+ }
+}
diff --git a/src/core/tests/Deal.Tests.Unit/Api/DealExceptionHandlerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DealExceptionHandlerTests.cs
new file mode 100644
index 0000000..48ec43a
--- /dev/null
+++ b/src/core/tests/Deal.Tests.Unit/Api/DealExceptionHandlerTests.cs
@@ -0,0 +1,83 @@
+using System.Text.Json;
+using Deal.Api.Middleware;
+using Deal.SharedKernel.Errors;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+
+namespace Deal.Tests.Unit.Api;
+
+///
+/// Тесты обработчика необработанных исключений HTTP.
+///
+public sealed class DealExceptionHandlerTests
+{
+ [Fact]
+ public async Task NotFound_MapsTo404WithCodeAndRussianDetail()
+ {
+ DefaultHttpContext context = CreateContext();
+ DealExceptionHandler handler = new(Substitute.For>());
+
+ bool handled = await handler.TryHandleAsync(
+ context,
+ new NotFoundException("Карточка", "c_1"),
+ CancellationToken.None);
+
+ Assert.True(handled);
+ Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
+ (string detail, string code) = await ReadBodyAsync(context);
+ Assert.Equal(DealErrorCodes.NotFound, code);
+ Assert.Contains("Карточка", detail);
+ }
+
+ [Fact]
+ public async Task Unavailable_MapsTo503()
+ {
+ DefaultHttpContext context = CreateContext();
+ DealExceptionHandler handler = new(Substitute.For>());
+
+ await handler.TryHandleAsync(
+ context,
+ new ServiceUnavailableException("ИИ"),
+ CancellationToken.None);
+
+ Assert.Equal(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
+ }
+
+ [Fact]
+ public async Task UnexpectedException_MapsToGeneric500WithoutStackOrDetails()
+ {
+ DefaultHttpContext context = CreateContext();
+ DealExceptionHandler handler = new(Substitute.For>());
+
+ bool handled = await handler.TryHandleAsync(
+ context,
+ new InvalidOperationException("секретная внутренняя деталь"),
+ CancellationToken.None);
+
+ Assert.True(handled);
+ Assert.Equal(StatusCodes.Status500InternalServerError, context.Response.StatusCode);
+ (string detail, string code) = await ReadBodyAsync(context);
+ Assert.Equal(DealErrorCodes.Internal, code);
+ Assert.DoesNotContain("секретная внутренняя деталь", detail);
+ Assert.DoesNotContain("at ", detail);
+ }
+
+ private static DefaultHttpContext CreateContext()
+ {
+ var context = new DefaultHttpContext();
+ context.Response.Body = new MemoryStream();
+ return context;
+ }
+
+ private static async Task<(string Detail, string Code)> ReadBodyAsync(HttpContext context)
+ {
+ context.Response.Body.Seek(0, SeekOrigin.Begin);
+ using var reader = new StreamReader(context.Response.Body);
+ string json = await reader.ReadToEndAsync();
+ using JsonDocument document = JsonDocument.Parse(json);
+ return (
+ document.RootElement.GetProperty("detail").GetString() ?? string.Empty,
+ document.RootElement.GetProperty("code").GetString() ?? string.Empty);
+ }
+}
diff --git a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs
index 8e12fd4..8d8ade1 100644
--- a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs
@@ -39,7 +39,7 @@ public sealed class DiscoveryWorkerSchedulerTests
TestDiscoveryStore StoreB,
TestDiscoveryGateway GatewayA,
TestDiscoveryGateway GatewayB,
- TenantContext TenantContext,
+ ITenantContext TenantContext,
ListLogger Logs);
[Fact]
@@ -91,7 +91,7 @@ public sealed class DiscoveryWorkerSchedulerTests
private static Context CreateContext()
{
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
- var tenantContext = new TenantContext();
+ ITenantContext tenantContext = new TenantContext();
var storeA = new TestDiscoveryStore();
var storeB = new TestDiscoveryStore();
var settingsA = new TestSettingsStore();
diff --git a/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs
index ef50d41..fb26843 100644
--- a/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs
@@ -41,7 +41,7 @@ public sealed class MlOutboxFlushSchedulerTests
{
var store = new TestMlLearningStore();
SeedRows(store, count: 25, prefix: "a");
- var tenantContext = new TenantContext();
+ ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
@@ -67,7 +67,7 @@ public sealed class MlOutboxFlushSchedulerTests
service.TrainUnavailable = true;
var store = new TestMlLearningStore();
SeedRows(store, count: 5, prefix: "a");
- var tenantContext = new TenantContext();
+ ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
@@ -95,7 +95,7 @@ public sealed class MlOutboxFlushSchedulerTests
SeedRows(storeA, count: 12, prefix: "a");
var storeB = new TestMlLearningStore();
SeedRows(storeB, count: 3, prefix: "b");
- var tenantContext = new TenantContext();
+ ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
@@ -121,7 +121,7 @@ public sealed class MlOutboxFlushSchedulerTests
{
var store = new TestMlLearningStore();
SeedRows(store, count: 105, prefix: "a");
- var tenantContext = new TenantContext();
+ ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
@@ -148,7 +148,7 @@ public sealed class MlOutboxFlushSchedulerTests
private static ServiceProvider BuildProvider(
int port,
TestTenantRepository tenants,
- TenantContext tenantContext,
+ ITenantContext tenantContext,
Dictionary storesByTenant)
{
var services = new ServiceCollection();
diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs
index 717e77e..d6ec709 100644
--- a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs
@@ -1,9 +1,10 @@
using Deal.Contracts.Integrations.Models;
-using Deal.Tests.Unit.Support;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
+using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings;
+using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Contracts;
@@ -343,13 +344,13 @@ public sealed class CardsServiceTests
}
[Fact]
- public async Task Trash_CardMissing_ReturnsNull()
+ public async Task Trash_CardMissing_ThrowsNotFound()
{
(CardsService service, _, _, TestMlClient ml) = Create();
- CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None);
+ await Assert.ThrowsAsync(
+ () => service.TrashCardAsync("l_ghost", CancellationToken.None));
- Assert.Null(result); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(ml.Pushed);
}
@@ -425,13 +426,12 @@ public sealed class CardsServiceTests
}
[Fact]
- public async Task Restore_CardMissing_ReturnsNull()
+ public async Task Restore_CardMissing_ThrowsNotFound()
{
(CardsService service, _, _, _) = Create();
- string? back = await service.RestoreCardAsync("l_ghost", CancellationToken.None);
-
- Assert.Null(back); // эндпоинт отвечает 404 «Карточка не найдена»
+ await Assert.ThrowsAsync(
+ () => service.RestoreCardAsync("l_ghost", CancellationToken.None));
}
diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs
index d00bd44..d6ff143 100644
--- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs
@@ -1,8 +1,9 @@
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Models;
-using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services;
+using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings;
+using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Discovery;
@@ -131,14 +132,13 @@ public sealed class DiscoveryTasksServiceTests
}
[Fact]
- public async Task Patch_MissingTask_ReturnsNull()
+ public async Task Patch_MissingTask_ThrowsNotFound()
{
(DiscoveryTasksService service, _, _) = Create();
- DiscoveryTaskDto? patched = await service.PatchAsync(
- "dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None);
-
- Assert.Null(patched);
+ await Assert.ThrowsAsync(
+ () => service.PatchAsync(
+ "dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None));
}
[Fact]
@@ -201,13 +201,12 @@ public sealed class DiscoveryTasksServiceTests
}
[Fact]
- public async Task Start_MissingTask_ReturnsNull()
+ public async Task Start_MissingTask_ThrowsNotFound()
{
(DiscoveryTasksService service, _, _) = Create();
- DiscoveryTaskDto? task = await service.StartAsync("dt_missing", CancellationToken.None);
-
- Assert.Null(task);
+ await Assert.ThrowsAsync(
+ () => service.StartAsync("dt_missing", CancellationToken.None));
}
[Fact]
@@ -281,9 +280,8 @@ public sealed class DiscoveryTasksServiceTests
store.SeedCandidate(Candidate("c_2", "dt_2"));
await store.Store.UpsertBlacklistAsync("c_1", "Источник", "причина", CancellationToken.None);
- bool deleted = await service.DeleteAsync("dt_1", CancellationToken.None);
+ await service.DeleteAsync("dt_1", CancellationToken.None);
- Assert.True(deleted);
Assert.Single(store.Tasks); // dt_2 осталась
Assert.Equal("dt_2", Assert.Single(store.Tasks).Id);
Assert.Single(store.Candidates); // кандидат dt_2 остался
@@ -292,13 +290,12 @@ public sealed class DiscoveryTasksServiceTests
}
[Fact]
- public async Task Delete_MissingTask_ReturnsFalse()
+ public async Task Delete_MissingTask_ThrowsNotFound()
{
(DiscoveryTasksService service, _, _) = Create();
- bool deleted = await service.DeleteAsync("dt_missing", CancellationToken.None);
-
- Assert.False(deleted);
+ await Assert.ThrowsAsync(
+ () => service.DeleteAsync("dt_missing", CancellationToken.None));
}
[Fact]
diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs
index cecfa82..d78ade6 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs
@@ -1,6 +1,7 @@
using System.Text;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
+using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings;
@@ -73,14 +74,13 @@ public sealed class CardsServiceFilesTests
}
[Fact]
- public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject()
+ public async Task Add_CardMissing_ThrowsAndDoesNotWriteObject()
{
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
- CardFileDto? entry = await service.AddFileAsync(
- "c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None);
+ await Assert.ThrowsAsync(() => service.AddFileAsync(
+ "c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None));
- Assert.Null(entry); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(storage.StoredObjectKeys); // «add на несуществующей карточке не пишет объект»
Assert.Empty(store.CardDtos);
}
@@ -151,25 +151,23 @@ public sealed class CardsServiceFilesTests
}
[Fact]
- public async Task GetEntry_CardMissing_ReturnsNull()
+ public async Task GetEntry_CardMissing_ThrowsNotFound()
{
(CardsService service, _, _) = Create();
- CardFileDto? entry = await service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None);
-
- Assert.Null(entry); // 404 «Карточка не найдена» у эндпоинта
+ await Assert.ThrowsAsync(
+ () => service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None));
}
[Fact]
- public async Task GetEntry_FileNotInMetadata_ReturnsNull()
+ public async Task GetEntry_FileNotInMetadata_ThrowsNotFound()
{
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1")
with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } });
- CardFileDto? entry = await service.GetFileEntryAsync("c_1", "pf_ghost", CancellationToken.None);
-
- Assert.Null(entry); // файла нет в метаданных карточки — 404-семантика
+ await Assert.ThrowsAsync(
+ () => service.GetFileEntryAsync("c_1", "pf_ghost", CancellationToken.None));
}
@@ -225,13 +223,13 @@ public sealed class CardsServiceFilesTests
}
[Fact]
- public async Task Remove_CardMissing_ReturnsNullWithoutStorageDelete()
+ public async Task Remove_CardMissing_ThrowsWithoutStorageDelete()
{
(CardsService service, _, TestFileStorage storage) = Create();
- CardDto? card = await service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None);
+ await Assert.ThrowsAsync(
+ () => service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None));
- Assert.Null(card); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(storage.DeletedKeys);
}
diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs
index e2317c2..871d678 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
+using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings;
@@ -99,13 +100,13 @@ public sealed class CardsServiceSelectedTests
[Fact]
- public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing()
+ public async Task TakeCard_CardMissing_ThrowsAndCreatesNothing()
{
(CardsService service, TestKanjStore store, _, _) = Create();
- CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None);
+ await Assert.ThrowsAsync(
+ () => service.TakeCardAsync("c_missing", CancellationToken.None));
- Assert.Null(card);
Assert.Empty(store.CardDtos);
}
@@ -122,8 +123,7 @@ public sealed class CardsServiceSelectedTests
stack: new[] { "Python", "aiogram" },
budget: new CardBudgetDto(From: 1600, To: 2200, Cur: "USD")));
- CardDto card = await service.TakeCardAsync("c_1", CancellationToken.None)
- ?? throw new InvalidOperationException("take вернул null при существующей карточке");
+ CardDto card = await service.TakeCardAsync("c_1", CancellationToken.None);
Assert.Equal("c_1", card.Id);
Assert.Equal("planned", card.Col);
@@ -182,8 +182,7 @@ public sealed class CardsServiceSelectedTests
("tzText", "ТЗ"),
("stack", new[] { "C#", ".NET" }), // стек — полная замена массива
("budget", new { from = 500, cur = "EUR" })),
- CancellationToken.None)
- ?? throw new InvalidOperationException("patch вернул null при существующей карточке");
+ CancellationToken.None);
Assert.Equal("Новый заголовок", card.Title);
Assert.Equal(string.Empty, card.Summary); // summary очищена пустой строкой
@@ -255,16 +254,14 @@ public sealed class CardsServiceSelectedTests
}
[Fact]
- public async Task Patch_CardMissing_ReturnsNull()
+ public async Task Patch_CardMissing_ThrowsNotFound()
{
(CardsService service, _, _, _) = Create();
- CardDto? card = await service.PatchCardAsync(
+ await Assert.ThrowsAsync(() => service.PatchCardAsync(
"c_missing",
PatchBody(("title", "Т")),
- CancellationToken.None);
-
- Assert.Null(card);
+ CancellationToken.None));
}
diff --git a/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs
index a329ea2..5084724 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
+using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -237,14 +238,12 @@ public sealed class ContainersServiceTests
}
[Fact]
- public async Task Patch_UnknownContainer_ReturnsNull()
+ public async Task Patch_UnknownContainer_ThrowsNotFound()
{
(ContainersService service, _, _) = Create();
- ContainerDto? result = await service.PatchAsync(
- "b_missing", Patch(name: "X"), CancellationToken.None);
-
- Assert.Null(result); // эндпоинт отвечает 404 «Контейнер не найден»
+ await Assert.ThrowsAsync(
+ () => service.PatchAsync("b_missing", Patch(name: "X"), CancellationToken.None));
}
[Fact]
diff --git a/src/core/tests/Deal.Tests.Unit/Support/ErrorResourcesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ErrorResourcesTests.cs
new file mode 100644
index 0000000..afeb132
--- /dev/null
+++ b/src/core/tests/Deal.Tests.Unit/Support/ErrorResourcesTests.cs
@@ -0,0 +1,34 @@
+using Deal.SharedKernel.Resources;
+
+namespace Deal.Tests.Unit.Support;
+
+///
+/// Тесты ресурсов текстов ошибок (ErrorMessages.resx).
+///
+public sealed class ErrorResourcesTests
+{
+ [Fact]
+ public void Format_KnownKey_ReturnsRussianText()
+ {
+ string text = ErrorResources.Format(ErrorResourceKeys.UnexpectedError);
+
+ Assert.Contains("Внутренняя ошибка", text);
+ }
+
+ [Fact]
+ public void Format_TemplateWithArgs_SubstitutesPlaceholders()
+ {
+ string text = ErrorResources.Format(ErrorResourceKeys.NotFoundEntityWithId, "Карточка", "c_1");
+
+ Assert.Contains("Карточка", text);
+ Assert.Contains("c_1", text);
+ }
+
+ [Fact]
+ public void Format_UnknownKey_ReturnsKey()
+ {
+ string text = ErrorResources.Format("NoSuchKey");
+
+ Assert.Equal("NoSuchKey", text);
+ }
+}
diff --git a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs
index 9bd8504..79a645d 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs
@@ -47,7 +47,7 @@ public sealed class PipelineWorkerSchedulerTests
TestKanjStore KanjB,
SseSubscription SubscriptionB,
PipelinePumpGate PumpGate,
- TenantContext TenantContext,
+ ITenantContext TenantContext,
ListLogger Logs);
// ─── Цикл: pump каждого тенанта в собственном scope + new_card ─────────
@@ -149,7 +149,7 @@ public sealed class PipelineWorkerSchedulerTests
private static Context CreateContext(bool withThrowingQueueReadA = false)
{
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
- var tenantContext = new TenantContext();
+ ITenantContext tenantContext = new TenantContext();
var pipelineA = new TestPipelineStore(throwOnList: withThrowingQueueReadA);
var pipelineB = new TestPipelineStore();
var kanjA = new TestKanjStore();
diff --git a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs
index e694a76..95198d0 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs
@@ -46,7 +46,7 @@ public sealed class StorageTickSchedulerTests
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var settings = new TestSettingsStore();
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
- var tenantContext = new TenantContext();
+ ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings);
SseBroker broker = provider.GetRequiredService();
@@ -74,7 +74,7 @@ public sealed class StorageTickSchedulerTests
var storeB = new TestKanjStore();
storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1))));
var settings = new TestSettingsStore();
- var tenantContext = new TenantContext();
+ ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
tenantContext,
@@ -100,7 +100,7 @@ public sealed class StorageTickSchedulerTests
{
// У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается.
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
- var tenantContext = new TenantContext();
+ ITenantContext tenantContext = new TenantContext();
var settingsByTenant = new Dictionary
{
[TenantA] = new ThrowingSettingsStore(),
@@ -133,7 +133,7 @@ public sealed class StorageTickSchedulerTests
[Fact]
public async Task RunCycle_TenantListFailure_DoesNotThrow()
{
- var tenantContext = new TenantContext();
+ ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new ThrowingTenantRepository(),
tenantContext,
@@ -152,7 +152,7 @@ public sealed class StorageTickSchedulerTests
{
var kanjStore = new TestKanjStore();
var settings = new TestSettingsStore();
- var tenantContext = new TenantContext();
+ ITenantContext tenantContext = new TenantContext();
var pipelineStoreA = new TestPipelineStore();
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
@@ -186,7 +186,7 @@ public sealed class StorageTickSchedulerTests
cardStoreA.SeedCard(HoldCard("c_a_future", title: "Будущий", reminderAtMs: NowMs() + 60_000));
cardStoreA.SeedCard(HoldCard("c_a_work", stage: "work", title: "В работе", reminderAtMs: NowMs() - 60_000));
var cardStoreB = new TestKanjStore();
- var tenantContext = new TenantContext();
+ ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
@@ -221,7 +221,7 @@ public sealed class StorageTickSchedulerTests
cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000));
var settingsA = new TestSettingsStore();
settingsA.Preload(SettingsKeys.RemindersEnabled, "false");
- var tenantContext = new TenantContext();
+ ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA)).Repository,
tenantContext,
@@ -247,7 +247,7 @@ public sealed class StorageTickSchedulerTests
// и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив.
var cardStoreA = new TestKanjStore(throwOnDueReminders: true);
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
- var tenantContext = new TenantContext();
+ ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
@@ -280,7 +280,7 @@ public sealed class StorageTickSchedulerTests
// Возвращает: Провайдер с зарегистрированными сервисами теста.
private static ServiceProvider BuildProvider(
TestTenantRepository tenants,
- TenantContext tenantContext,
+ ITenantContext tenantContext,
TestKanjStore storeA,
TestKanjStore storeB,
TestSettingsStore settings)
@@ -301,7 +301,7 @@ public sealed class StorageTickSchedulerTests
// Возвращает: Провайдер с зарегистрированными сервисами теста.
private static ServiceProvider BuildProvider(
ITenantRepository tenants,
- TenantContext tenantContext,
+ ITenantContext tenantContext,
Dictionary storesByTenant,
Dictionary settingsByTenant,
Dictionary? pipelineStoresByTenant = null)
diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj b/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj
index 2bf63ff..eb8e068 100644
--- a/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj
+++ b/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj
@@ -30,6 +30,11 @@
+
+
+
+
+
diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs
index a7902a5..ed0804e 100644
--- a/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs
+++ b/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs
@@ -2,6 +2,8 @@ using System.Diagnostics;
using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services;
+using Deal.SharedKernel.Errors;
+using Deal.SharedKernel.Resources;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.Logging;
@@ -16,9 +18,6 @@ public sealed class RpcCallLoggingInterceptor : Interceptor
// Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness).
private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
- // Деталь RpcException для сбоя реализации (фиксированный текст; детали ошибки не наружу).
- private const string UnknownFailureDetail = "Внутренняя ошибка сервиса";
-
private readonly ILogger _logger;
///
@@ -101,14 +100,46 @@ public sealed class RpcCallLoggingInterceptor : Interceptor
}
catch (Exception exception)
{
- // «Прочие» сбои реализации gRPC показал бы клиенту как UNKNOWN мимо access-лога: логируем
- // строку со статусом Unknown, пишем детали сбоя и переводим в RpcException (текст фиксирован).
- _logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", context.Method);
- LogCall(context, startedAt, StatusCode.Unknown);
- throw new RpcException(new Status(StatusCode.Unknown, UnknownFailureDetail));
+ RpcException mapped = MapFailure(exception, context.Method);
+ LogCall(context, startedAt, mapped.Status.StatusCode);
+ throw mapped;
}
}
+ // Переводит сбой реализации в RpcException: доменные ошибки — по коду, прочие — Unknown
+ // с фиксированным текстом (детали и стектрейс остаются только в логе).
+ // exception: Сбой обработчика.
+ // rpcMethod: Полное имя RPC-метода (для лога).
+ // Возвращает: RpcException для клиента.
+ private RpcException MapFailure(Exception exception, string rpcMethod)
+ {
+ if (exception is DealException dealException)
+ {
+ _logger.LogWarning(
+ "gRPC {RpcMethod}: доменная ошибка {ErrorCode}: {Message}",
+ rpcMethod,
+ dealException.ErrorCode,
+ dealException.Message);
+ return new RpcException(new Status(MapErrorCode(dealException.ErrorCode), dealException.Message));
+ }
+
+ _logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", rpcMethod);
+ return new RpcException(
+ new Status(StatusCode.Unknown, ErrorResources.Format(ErrorResourceKeys.UnexpectedError)));
+ }
+
+ // Код ошибки Deal → статус gRPC.
+ // errorCode: Код из DealException.ErrorCode.
+ // Возвращает: Статус gRPC для клиента.
+ private static StatusCode MapErrorCode(string errorCode) => errorCode switch
+ {
+ DealErrorCodes.NotFound => StatusCode.NotFound,
+ DealErrorCodes.Validation => StatusCode.InvalidArgument,
+ DealErrorCodes.Conflict => StatusCode.FailedPrecondition,
+ DealErrorCodes.Unavailable => StatusCode.Unavailable,
+ _ => StatusCode.Internal,
+ };
+
// Обёртка для handler-ов, возвращающих Task (server-streaming/дуплексный).
// context: Контекст вызова (метод — context.Method).
// invoke: Вызов нижестоящего обработчика.
diff --git a/src/telegram-service/Deal.Telegram/Core/CoreIngressClient.cs b/src/telegram-service/Deal.Telegram/Core/CoreIngressClient.cs
index a70bff6..866be39 100644
--- a/src/telegram-service/Deal.Telegram/Core/CoreIngressClient.cs
+++ b/src/telegram-service/Deal.Telegram/Core/CoreIngressClient.cs
@@ -11,9 +11,6 @@ using Deal.Telegram.Core;
namespace Deal.Telegram.Core;
-///
-/// Исходящий gRPC-канал в ядро
-///
public sealed class CoreIngressClient : ICoreIngressClient
{
public const string TenantIdMetadataKey = "tenant-id";
@@ -44,7 +41,6 @@ public sealed class CoreIngressClient : ICoreIngressClient
_mtlsCertificates = mtlsCertificates;
}
- ///
async Task ICoreIngressClient.PushSourceAsync(
string tenantId,
PushSourceRequest request,
@@ -61,7 +57,6 @@ public sealed class CoreIngressClient : ICoreIngressClient
}
}
- ///
async Task> ICoreIngressClient.SyncDialogsAsync(
string tenantId,
IReadOnlyList entries,
diff --git a/src/telegram-service/Deal.Telegram/Dialogs/RandomBackfillPacer.cs b/src/telegram-service/Deal.Telegram/Dialogs/RandomBackfillPacer.cs
index ad2a112..41e168c 100644
--- a/src/telegram-service/Deal.Telegram/Dialogs/RandomBackfillPacer.cs
+++ b/src/telegram-service/Deal.Telegram/Dialogs/RandomBackfillPacer.cs
@@ -1,12 +1,8 @@
using Deal.Telegram.Dialogs;
namespace Deal.Telegram.Dialogs;
-///
-/// Реальная реализация
-///
public sealed class RandomBackfillPacer : IBackfillPacer
{
- ///
async Task IBackfillPacer.WaitAsync(
double minSeconds,
double maxSeconds,
diff --git a/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs b/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs
index 9051ac9..3e51e14 100644
--- a/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs
+++ b/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs
@@ -1,12 +1,8 @@
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Telegram;
-///
-/// Фабрика реальных клиентов WTelegramClient.
-///
public sealed class ClientFactory : ITelegramClientFactory
{
- ///
ISessionClient ITelegramClientFactory.Create(
int apiId,
string apiHash,
diff --git a/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs b/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs
index 31da04c..211670e 100644
--- a/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs
+++ b/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs
@@ -11,9 +11,6 @@ namespace Deal.Telegram.Telegram;
#pragma warning disable CS0618 // Auth_SendCode/Auth_SignIn используются осознанно: ручной веб-вход 1:1 с прототипом
-///
-/// Реальная реализация поверх WTelegramClient.
-///
public sealed class WTelegramSessionClient : ISessionClient
{
private readonly Client _client;
@@ -67,25 +64,19 @@ public sealed class WTelegramSessionClient : ISessionClient
_updateManager = new UpdateManager(_client, OnSingleUpdateAsync);
}
- ///
- public bool IsAuthorized => _client.UserId != 0;
+ bool ISessionClient.IsAuthorized => _client.UserId != 0;
- ///
- public bool IsConnected => _connected && !_client.Disconnected;
+ bool ISessionClient.IsConnected => _connected && !_client.Disconnected;
- ///
- public int ApiId => _apiId;
+ int ISessionClient.ApiId => _apiId;
- ///
- public string ApiHash => _apiHash;
+ string ISessionClient.ApiHash => _apiHash;
- ///
- public byte[]? SessionBytes => Volatile.Read(ref _latestSessionBytes);
+ byte[]? ISessionClient.SessionBytes => Volatile.Read(ref _latestSessionBytes);
- ///
async Task ISessionClient.ConnectAsync(CancellationToken cancellationToken)
{
- if (IsConnected)
+ if (((ISessionClient)this).IsConnected)
{
return;
}
@@ -94,7 +85,6 @@ public sealed class WTelegramSessionClient : ISessionClient
_connected = true;
}
- ///
async Task ISessionClient.RequestCodeAsync(string phone, CancellationToken cancellationToken)
{
_phone = phone;
@@ -118,7 +108,6 @@ public sealed class WTelegramSessionClient : ISessionClient
}
}
- ///
async Task ISessionClient.SubmitCodeAsync(string code, CancellationToken cancellationToken)
{
if (_phoneAlreadyAuthorized)
@@ -158,7 +147,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return null;
}
- ///
async Task ISessionClient.SubmitPasswordAsync(string password, CancellationToken cancellationToken)
{
try
@@ -178,7 +166,6 @@ public sealed class WTelegramSessionClient : ISessionClient
}
}
- ///
async Task ISessionClient.StartQrAsync(Action onQrUrl, CancellationToken cancellationToken)
{
try
@@ -196,13 +183,11 @@ public sealed class WTelegramSessionClient : ISessionClient
}
}
- ///
async Task ISessionClient.LogOutAsync(CancellationToken cancellationToken)
{
await _client.Auth_LogOut().WaitAsync(cancellationToken).ConfigureAwait(false);
}
- ///
async Task ISessionClient.GetAccountAsync(CancellationToken cancellationToken)
{
UserBase[] users = await _client.Users_GetUsers(InputUser.Self).WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -221,7 +206,6 @@ public sealed class WTelegramSessionClient : ISessionClient
///
public event Func? MessageReceived;
- ///
async Task> ISessionClient.GetDialogsAsync(int limit, CancellationToken cancellationToken)
{
Messages_DialogsBase result = await RunTlCallAsync(() => _client.Messages_GetDialogs(limit: limit), cancellationToken).ConfigureAwait(false);
@@ -241,7 +225,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return items;
}
- ///
async Task> ISessionClient.GetMessagesAsync(
string dialogId,
int limit,
@@ -265,7 +248,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return items;
}
- ///
async Task ISessionClient.GetMessageAsync(
string dialogId,
long msgId,
@@ -295,7 +277,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return null;
}
- ///
async Task ISessionClient.MarkReadAsync(string dialogId, CancellationToken cancellationToken)
{
InputPeer peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false);
@@ -303,7 +284,6 @@ public sealed class WTelegramSessionClient : ISessionClient
}
- ///
async Task> ISessionClient.SearchAsync(
string query,
int limit,
@@ -327,7 +307,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return items;
}
- ///
async Task ISessionClient.GetInfoAsync(string dialogId, CancellationToken cancellationToken)
{
TelegramSourceInfo unknown = DefaultSourceInfo(dialogId);
@@ -361,7 +340,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return unknown;
}
- ///
async Task ISessionClient.ReadForEvalAsync(
string dialogId,
int limit,
@@ -410,7 +388,6 @@ public sealed class WTelegramSessionClient : ISessionClient
}
}
- ///
async Task ISessionClient.JoinAsync(string username, CancellationToken cancellationToken)
{
Contacts_ResolvedPeer resolved = await RunTlCallAsync(() => _client.Contacts_ResolveUsername(username), cancellationToken).ConfigureAwait(false);
@@ -425,7 +402,6 @@ public sealed class WTelegramSessionClient : ISessionClient
await RunTlCallAsync(() => _client.Channels_JoinChannel(new InputChannel(channel.id, channel.access_hash)), cancellationToken).ConfigureAwait(false);
}
- ///
async Task ISessionClient.LeaveAsync(string dialogId, CancellationToken cancellationToken)
{
if (!TryParseSignedId(dialogId, out bool isChannel, out _, out _, out long rawId) || !isChannel)