Merge branch 'main' into t2_grpc_retry
ci / build-test (pull_request) Successful in 3m0s

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