Compare commits
5
Commits
79793a7635
...
beaf20df42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beaf20df42 | ||
|
|
4526532b1a | ||
|
|
8661faea70 | ||
|
|
685c5fdf46 | ||
|
|
7c43c40282 |
@@ -242,11 +242,23 @@
|
|||||||
|
|
||||||
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
|
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
|
||||||
- При пробрасывании выше — `throw;`, а **не** `throw ex;`.
|
- При пробрасывании выше — `throw;`, а **не** `throw ex;`.
|
||||||
- Свои исключения наследовать от `Exception`.
|
- **Свои доменные исключения наследовать от `DealException`** (`Deal.SharedKernel.Errors`) — базовый тип
|
||||||
|
хранит код ошибки (`ErrorCode`) и умеет брать текст из ресурсов. Состав: `NotFoundException`,
|
||||||
|
`ValidationException`, `ConflictException`, `ServiceUnavailableException`; новые — по тому же образцу.
|
||||||
|
- **Не возвращать `null` как штатный результат «не найдено»/ошибки.** Доменный сервис, у которого объект
|
||||||
|
не найден, бросает `NotFoundException` (эндпоинт отдаёт 404 через общий обработчик, а не проверкой
|
||||||
|
`is null` в каждом хендлере). `null` допустим только для **опциональных значений** — парсеры/извлечение
|
||||||
|
полей, выборки-запросы («нет строки» — нормальный результат), `Try*`-паттерн; такие методы должны быть
|
||||||
|
nullable-аннотированы и явно описаны в XML-doc.
|
||||||
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
|
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
|
||||||
БД, неизвестные идентификаторы и т.п.).
|
БД, неизвестные идентификаторы и т.п.).
|
||||||
- Все исключения должны быть залогированы или показаны пользователю; пустые `catch` запрещены.
|
- Все исключения должны быть залогированы или показаны пользователю; **пустые `catch` запрещены**.
|
||||||
- В лог об ошибке, как правило, писать `StackTrace`.
|
- **Единый формат лога ошибки:** понятный русский текст + структурированный контекст (операция, `tenantId`,
|
||||||
|
id сущности, `traceId`). Стектрейс пишется **только в лог**; в ответ/сообщение клиенту он не попадает —
|
||||||
|
наружу отдаётся обобщённый текст и код (обработчики на границах: `DealExceptionHandler`, gRPC-интерцептор).
|
||||||
|
- **Тексты исключений/ошибок не хардкодить** — держать в ресурсах (`ErrorMessages.resx`, доступ через
|
||||||
|
`ErrorResources.Format(ErrorResourceKeys.*)` и шаблоны `DealException`), чтобы переводы добавлялись
|
||||||
|
отдельной культурой (`.resx`-спутник) без правок кода.
|
||||||
|
|
||||||
## 11. Интерфейсы
|
## 11. Интерфейсы
|
||||||
|
|
||||||
|
|||||||
@@ -136,10 +136,8 @@ public static class CardDetailsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto? card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
|
CardDto card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
|
||||||
return card is null
|
return await ReadCardAsync(context, card.Id, ct);
|
||||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
|
||||||
: await ReadCardAsync(context, card.Id, ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
|
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
|
||||||
@@ -261,11 +259,7 @@ public static class CardDetailsEndpoints
|
|||||||
foreach (IFormFile file in form.Files)
|
foreach (IFormFile file in form.Files)
|
||||||
{
|
{
|
||||||
await using Stream content = file.OpenReadStream();
|
await using Stream content = file.OpenReadStream();
|
||||||
CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
|
await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
|
||||||
if (entry is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await ReadCardAsync(context, cardId, ct);
|
return await ReadCardAsync(context, cardId, ct);
|
||||||
@@ -286,11 +280,7 @@ public static class CardDetailsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardFileDto? entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
|
CardFileDto entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
|
||||||
if (entry is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(entry.ObjectKey))
|
if (string.IsNullOrWhiteSpace(entry.ObjectKey))
|
||||||
{
|
{
|
||||||
@@ -339,10 +329,8 @@ public static class CardDetailsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct);
|
await cardsService.RemoveFileAsync(cardId, fileId, ct);
|
||||||
return card is null
|
return await ReadCardAsync(context, cardId, ct);
|
||||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
|
||||||
: await ReadCardAsync(context, cardId, ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
|
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
|
||||||
|
|||||||
@@ -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 ?? "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Deal.Modules.Discovery.Application.Extensions;
|
|||||||
using Deal.Modules.Discovery.Application.Models;
|
using Deal.Modules.Discovery.Application.Models;
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.SharedKernel.Errors;
|
||||||
|
|
||||||
namespace Deal.Modules.Discovery.Application.Services;
|
namespace Deal.Modules.Discovery.Application.Services;
|
||||||
|
|
||||||
@@ -12,6 +13,9 @@ namespace Deal.Modules.Discovery.Application.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings)
|
public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings)
|
||||||
{
|
{
|
||||||
|
// Имя сущности для текста ошибки «не найдено».
|
||||||
|
private const string TaskEntityName = "Задача поиска";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 400 create: пустое название после Trim.
|
/// 400 create: пустое название после Trim.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -35,10 +39,12 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
|
|||||||
/// Одна задача по id.
|
/// Одна задача по id.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
||||||
/// <returns>Задача или null (404 «Задача не найдена» у эндпоинта).</returns>
|
/// <returns>Задача.</returns>
|
||||||
public Task<DiscoveryTaskDto?> GetAsync(string taskId, CancellationToken ct)
|
/// <exception cref="NotFoundException">Задача не найдена.</exception>
|
||||||
|
public async Task<DiscoveryTaskDto> GetAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return store.GetTaskAsync(taskId, ct);
|
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
|
||||||
|
?? throw new NotFoundException(TaskEntityName, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -102,18 +108,16 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
||||||
/// <param name="patch">Изменяемые поля (null — не меняется).</param>
|
/// <param name="patch">Изменяемые поля (null — не меняется).</param>
|
||||||
/// <returns>Обновлённая задача либо null (404 «Задача не найдена»).</returns>
|
/// <returns>Обновлённая задача.</returns>
|
||||||
|
/// <exception cref="NotFoundException">Задача не найдена.</exception>
|
||||||
/// <exception cref="DiscoveryValidationException">Новый план вне границ / бюджет исчерпан.</exception>
|
/// <exception cref="DiscoveryValidationException">Новый план вне границ / бюджет исчерпан.</exception>
|
||||||
public async Task<DiscoveryTaskDto?> PatchAsync(
|
public async Task<DiscoveryTaskDto> PatchAsync(
|
||||||
string taskId,
|
string taskId,
|
||||||
DiscoveryTaskPatch patch,
|
DiscoveryTaskPatch patch,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
|
DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
|
||||||
if (current is null)
|
?? throw new NotFoundException(TaskEntityName, taskId);
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
DiscoveryTaskPatch normalized = NormalizeTaskPatch(patch);
|
DiscoveryTaskPatch normalized = NormalizeTaskPatch(patch);
|
||||||
if (normalized.PlanJoins is int newPlan)
|
if (normalized.PlanJoins is int newPlan)
|
||||||
@@ -131,38 +135,34 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
|
|||||||
}
|
}
|
||||||
|
|
||||||
await store.PatchTaskAsync(taskId, normalized, ct).ConfigureAwait(false);
|
await store.PatchTaskAsync(taskId, normalized, ct).ConfigureAwait(false);
|
||||||
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
|
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
|
||||||
|
?? throw new NotFoundException(TaskEntityName, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Удаляет задачу вместе с кандидатами и логом.
|
/// Удаляет задачу вместе с кандидатами и логом.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
||||||
/// <returns>True — задача удалена; false — строки нет (404 у эндпоинта).</returns>
|
/// <exception cref="NotFoundException">Задача не найдена.</exception>
|
||||||
public async Task<bool> DeleteAsync(string taskId, CancellationToken ct)
|
public async Task DeleteAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
|
DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
|
||||||
if (current is null)
|
?? throw new NotFoundException(TaskEntityName, taskId);
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await store.DeleteTaskAsync(taskId, ct).ConfigureAwait(false);
|
await store.DeleteTaskAsync(current.Id, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Запускает поиск
|
/// Запускает поиск
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
||||||
/// <returns>Задача в running либо null (404).</returns>
|
/// <returns>Задача в running.</returns>
|
||||||
|
/// <exception cref="NotFoundException">Задача не найдена.</exception>
|
||||||
/// <exception cref="DiscoveryValidationException">Ключевых слов нет.</exception>
|
/// <exception cref="DiscoveryValidationException">Ключевых слов нет.</exception>
|
||||||
public async Task<DiscoveryTaskDto?> StartAsync(string taskId, CancellationToken ct)
|
public async Task<DiscoveryTaskDto> StartAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
|
DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
|
||||||
if (current is null)
|
?? throw new NotFoundException(TaskEntityName, taskId);
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.Keywords.Count == 0)
|
if (current.Keywords.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -171,24 +171,24 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
|
|||||||
|
|
||||||
bool resetProgress = DiscoveryTaskStatuses.IsFinished(current.Status);
|
bool resetProgress = DiscoveryTaskStatuses.IsFinished(current.Status);
|
||||||
await store.SetTaskRunningAsync(taskId, resetProgress, ct).ConfigureAwait(false);
|
await store.SetTaskRunningAsync(taskId, resetProgress, ct).ConfigureAwait(false);
|
||||||
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
|
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
|
||||||
|
?? throw new NotFoundException(TaskEntityName, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ставит задачу на паузу.
|
/// Ставит задачу на паузу.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
||||||
/// <returns>Задача в paused либо null (404).</returns>
|
/// <returns>Задача в paused.</returns>
|
||||||
public async Task<DiscoveryTaskDto?> PauseAsync(string taskId, CancellationToken ct)
|
/// <exception cref="NotFoundException">Задача не найдена.</exception>
|
||||||
|
public async Task<DiscoveryTaskDto> PauseAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
|
DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
|
||||||
if (current is null)
|
?? throw new NotFoundException(TaskEntityName, taskId);
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await store.SetTaskPausedAsync(taskId, ct).ConfigureAwait(false);
|
await store.SetTaskPausedAsync(current.Id, ct).ConfigureAwait(false);
|
||||||
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
|
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
|
||||||
|
?? throw new NotFoundException(TaskEntityName, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Deal.Contracts.Integrations.Abstractions;
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
|
using Deal.SharedKernel.Errors;
|
||||||
|
|
||||||
namespace Deal.Modules.Kanban.Application.Services;
|
namespace Deal.Modules.Kanban.Application.Services;
|
||||||
|
|
||||||
@@ -18,6 +19,10 @@ public sealed partial class CardsService
|
|||||||
|
|
||||||
private const string DefaultAttachmentName = "file";
|
private const string DefaultAttachmentName = "file";
|
||||||
|
|
||||||
|
// Имена сущностей для текстов ошибок «не найдено».
|
||||||
|
private const string CardEntityName = "Карточка";
|
||||||
|
private const string CardFileEntityName = "Файл карточки";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Добавляет файл карточке
|
/// Добавляет файл карточке
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -26,8 +31,9 @@ public sealed partial class CardsService
|
|||||||
/// <param name="contentType">MIME-тип загрузки (может быть null/пустым — детект по расширению).</param>
|
/// <param name="contentType">MIME-тип загрузки (может быть null/пустым — детект по расширению).</param>
|
||||||
/// <param name="content">Поток содержимого файла (читается хранилищем с позиции 0).</param>
|
/// <param name="content">Поток содержимого файла (читается хранилищем с позиции 0).</param>
|
||||||
/// <param name="size">Длина содержимого в байтах (пишется в метаданные записи).</param>
|
/// <param name="size">Длина содержимого в байтах (пишется в метаданные записи).</param>
|
||||||
/// <returns>Метаданные добавленного файла или null — карточки нет (404).</returns>
|
/// <returns>Метаданные добавленного файла.</returns>
|
||||||
public async Task<CardFileDto?> AddFileAsync(
|
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
||||||
|
public async Task<CardFileDto> AddFileAsync(
|
||||||
string cardId,
|
string cardId,
|
||||||
string fileName,
|
string fileName,
|
||||||
string? contentType,
|
string? contentType,
|
||||||
@@ -37,11 +43,8 @@ public sealed partial class CardsService
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(content);
|
ArgumentNullException.ThrowIfNull(content);
|
||||||
|
|
||||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
CardDto card = await _store.GetCardAsync(cardId, ct)
|
||||||
if (card is null)
|
?? throw new NotFoundException(CardEntityName, cardId);
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
string name = string.IsNullOrWhiteSpace(fileName) ? DefaultAttachmentName : fileName;
|
string name = string.IsNullOrWhiteSpace(fileName) ? DefaultAttachmentName : fileName;
|
||||||
CardFileKind kind = FileKindDetector.Detect(name, contentType);
|
CardFileKind kind = FileKindDetector.Detect(name, contentType);
|
||||||
@@ -62,7 +65,7 @@ public sealed partial class CardsService
|
|||||||
// Карточка исчезла между чтением и записью (гонка): объект-сирота в хранилище не нужен —
|
// Карточка исчезла между чтением и записью (гонка): объект-сирота в хранилище не нужен —
|
||||||
// удаляем и отвечаем 404-семантикой (DeleteAsync сбои не бросает).
|
// удаляем и отвечаем 404-семантикой (DeleteAsync сбои не бросает).
|
||||||
await _storage.DeleteAsync(objectKey, ct);
|
await _storage.DeleteAsync(objectKey, ct);
|
||||||
return null;
|
throw new NotFoundException(CardEntityName, cardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
@@ -73,19 +76,18 @@ public sealed partial class CardsService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||||
/// <param name="fileId">Id записи файла (<c>pf_...</c>).</param>
|
/// <param name="fileId">Id записи файла (<c>pf_...</c>).</param>
|
||||||
/// <returns>Метаданные записи файла либо null (карточка/запись не найдены).</returns>
|
/// <returns>Метаданные записи файла.</returns>
|
||||||
public async Task<CardFileDto?> GetFileEntryAsync(
|
/// <exception cref="NotFoundException">Карточка или запись файла не найдены.</exception>
|
||||||
|
public async Task<CardFileDto> GetFileEntryAsync(
|
||||||
string cardId,
|
string cardId,
|
||||||
string fileId,
|
string fileId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
CardDto card = await _store.GetCardAsync(cardId, ct)
|
||||||
if (card is null)
|
?? throw new NotFoundException(CardEntityName, cardId);
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return card.Files.FirstOrDefault(file => file.Id == fileId);
|
return card.Files.FirstOrDefault(file => file.Id == fileId)
|
||||||
|
?? throw new NotFoundException(CardFileEntityName, fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -93,17 +95,15 @@ public sealed partial class CardsService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||||
/// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param>
|
/// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param>
|
||||||
/// <returns>Карточка после удаления (без записи) либо null — карточки нет (404-семантика).</returns>
|
/// <returns>Карточка после удаления (без записи).</returns>
|
||||||
public async Task<CardDto?> RemoveFileAsync(
|
/// <exception cref="NotFoundException">Карточка или запись файла не найдены.</exception>
|
||||||
|
public async Task<CardDto> RemoveFileAsync(
|
||||||
string cardId,
|
string cardId,
|
||||||
string fileId,
|
string fileId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
CardDto card = await _store.GetCardAsync(cardId, ct)
|
||||||
if (card is null)
|
?? throw new NotFoundException(CardEntityName, cardId);
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
CardFileDto? entry = card.Files.FirstOrDefault(file => file.Id == fileId);
|
CardFileDto? entry = card.Files.FirstOrDefault(file => file.Id == fileId);
|
||||||
if (entry is not null && !string.IsNullOrWhiteSpace(entry.ObjectKey))
|
if (entry is not null && !string.IsNullOrWhiteSpace(entry.ObjectKey))
|
||||||
@@ -113,11 +113,11 @@ public sealed partial class CardsService
|
|||||||
|
|
||||||
if (!await _store.RemoveFileAsync(cardId, fileId, ct))
|
if (!await _store.RemoveFileAsync(cardId, fileId, ct))
|
||||||
{
|
{
|
||||||
return null;
|
throw new NotFoundException(CardFileEntityName, fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await _store.GetCardAsync(cardId, ct)
|
return await _store.GetCardAsync(cardId, ct)
|
||||||
?? throw new InvalidOperationException("Карточка не прочиталась после удаления файла: " + cardId);
|
?? throw new NotFoundException(CardEntityName, cardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildObjectKey(
|
private static string BuildObjectKey(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models;
|
|||||||
using Deal.Modules.Cards.Application.Models;
|
using Deal.Modules.Cards.Application.Models;
|
||||||
using Deal.Modules.Kanban.Application.Abstractions;
|
using Deal.Modules.Kanban.Application.Abstractions;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
|
using Deal.SharedKernel.Errors;
|
||||||
|
|
||||||
namespace Deal.Modules.Kanban.Application.Services;
|
namespace Deal.Modules.Kanban.Application.Services;
|
||||||
|
|
||||||
@@ -87,8 +88,9 @@ public sealed partial class CardsService
|
|||||||
/// Перенос карточки в корзину
|
/// Перенос карточки в корзину
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||||
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
|
/// <returns>Карточка после переноса (при no-op — как была).</returns>
|
||||||
public Task<CardDto?> TrashCardAsync(string cardId, CancellationToken ct)
|
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
||||||
|
public Task<CardDto> TrashCardAsync(string cardId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return TrashCardAsync(cardId, teach: true, ct);
|
return TrashCardAsync(cardId, teach: true, ct);
|
||||||
}
|
}
|
||||||
@@ -98,17 +100,15 @@ public sealed partial class CardsService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||||
/// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param>
|
/// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param>
|
||||||
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
|
/// <returns>Карточка после переноса (при no-op — как была).</returns>
|
||||||
public async Task<CardDto?> TrashCardAsync(
|
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
||||||
|
public async Task<CardDto> TrashCardAsync(
|
||||||
string cardId,
|
string cardId,
|
||||||
bool teach,
|
bool teach,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
CardDto card = await _store.GetCardAsync(cardId, ct)
|
||||||
if (card is null)
|
?? throw new NotFoundException(CardEntityName, cardId);
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (card.Col == CardIds.Trash)
|
if (card.Col == CardIds.Trash)
|
||||||
{
|
{
|
||||||
@@ -122,21 +122,20 @@ public sealed partial class CardsService
|
|||||||
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct);
|
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await _store.GetCardAsync(cardId, ct);
|
return await _store.GetCardAsync(cardId, ct)
|
||||||
|
?? throw new NotFoundException(CardEntityName, cardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Возврат карточки из архива/корзины на канбан.
|
/// Возврат карточки из архива/корзины на канбан.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||||
/// <returns>Колонка возврата (inbox/доска) либо null — карточки нет (404).</returns>
|
/// <returns>Колонка возврата (inbox/доска).</returns>
|
||||||
public async Task<string?> RestoreCardAsync(string cardId, CancellationToken ct)
|
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
||||||
|
public async Task<string> RestoreCardAsync(string cardId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
CardDto card = await _store.GetCardAsync(cardId, ct)
|
||||||
if (card is null)
|
?? throw new NotFoundException(CardEntityName, cardId);
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
string back = await ResolveReturnColAsync(card.PrevCol, ct);
|
string back = await ResolveReturnColAsync(card.PrevCol, ct);
|
||||||
string text = LearningText(card);
|
string text = LearningText(card);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Deal.Modules.Cards.Application.Models;
|
|||||||
using Deal.Modules.Cards.Application.Sources;
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
using Deal.Modules.Kanban.Application.Abstractions;
|
using Deal.Modules.Kanban.Application.Abstractions;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
|
using Deal.SharedKernel.Errors;
|
||||||
|
|
||||||
namespace Deal.Modules.Kanban.Application.Services;
|
namespace Deal.Modules.Kanban.Application.Services;
|
||||||
|
|
||||||
@@ -101,14 +102,12 @@ public sealed partial class CardsService
|
|||||||
/// «Взять в работу»
|
/// «Взять в работу»
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||||
/// <returns>Карточка в стадии planned; null — карточки нет (404).</returns>
|
/// <returns>Карточка в стадии planned.</returns>
|
||||||
public async Task<CardDto?> TakeCardAsync(string cardId, CancellationToken ct)
|
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
||||||
|
public async Task<CardDto> TakeCardAsync(string cardId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
CardDto card = await _store.GetCardAsync(cardId, ct)
|
||||||
if (card is null)
|
?? throw new NotFoundException(CardEntityName, cardId);
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CardsDefaultContainers.Contains(card.Col))
|
if (CardsDefaultContainers.Contains(card.Col))
|
||||||
{
|
{
|
||||||
@@ -120,14 +119,14 @@ public sealed partial class CardsService
|
|||||||
if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct))
|
if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct))
|
||||||
{
|
{
|
||||||
// Карточка исчезла между чтением и переносом (гонка с удалением).
|
// Карточка исчезла между чтением и переносом (гонка с удалением).
|
||||||
return null;
|
throw new NotFoundException(CardEntityName, cardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _store.AddCommentAsync(
|
await _store.AddCommentAsync(
|
||||||
PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct);
|
PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct);
|
||||||
|
|
||||||
return await _store.GetCardAsync(cardId, ct)
|
return await _store.GetCardAsync(cardId, ct)
|
||||||
?? throw new InvalidOperationException("Карточка не прочиталась после take: " + cardId);
|
?? throw new NotFoundException(CardEntityName, cardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -135,8 +134,9 @@ public sealed partial class CardsService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||||
/// <param name="body">Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).</param>
|
/// <param name="body">Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).</param>
|
||||||
/// <returns>Обновлённая карточка или null — карточки нет (404).</returns>
|
/// <returns>Обновлённая карточка.</returns>
|
||||||
public async Task<CardDto?> PatchCardAsync(
|
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
||||||
|
public async Task<CardDto> PatchCardAsync(
|
||||||
string cardId,
|
string cardId,
|
||||||
IReadOnlyDictionary<string, JsonElement> body,
|
IReadOnlyDictionary<string, JsonElement> body,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -144,7 +144,13 @@ public sealed partial class CardsService
|
|||||||
ArgumentNullException.ThrowIfNull(body);
|
ArgumentNullException.ThrowIfNull(body);
|
||||||
|
|
||||||
bool updated = await _store.PatchCardAsync(cardId, ResolvePatch(body), ct);
|
bool updated = await _store.PatchCardAsync(cardId, ResolvePatch(body), ct);
|
||||||
return updated ? await _store.GetCardAsync(cardId, ct) : null;
|
if (!updated)
|
||||||
|
{
|
||||||
|
throw new NotFoundException(CardEntityName, cardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _store.GetCardAsync(cardId, ct)
|
||||||
|
?? throw new NotFoundException(CardEntityName, cardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Deal.Modules.Kanban.Application.Abstractions;
|
|||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.SharedKernel.Errors;
|
||||||
|
|
||||||
namespace Deal.Modules.Kanban.Application.Services;
|
namespace Deal.Modules.Kanban.Application.Services;
|
||||||
|
|
||||||
@@ -19,6 +20,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string DefaultContainerName = "Новая колонка";
|
public const string DefaultContainerName = "Новая колонка";
|
||||||
|
|
||||||
|
// Имя сущности для текста ошибки «не найдено».
|
||||||
|
private const string ContainerEntityName = "Контейнер";
|
||||||
|
|
||||||
// Палитра колонок по умолчанию: цвет = Palette[order % 8], если цвет не задан.
|
// Палитра колонок по умолчанию: цвет = Palette[order % 8], если цвет не задан.
|
||||||
private static readonly string[] Palette =
|
private static readonly string[] Palette =
|
||||||
["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"];
|
["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"];
|
||||||
@@ -53,17 +57,15 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Один контейнер со счётчиками; null — контейнера нет.
|
/// Один контейнер со счётчиками
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="containerId">Id контейнера.</param>
|
/// <param name="containerId">Id контейнера.</param>
|
||||||
/// <returns>Контейнер со счётчиками либо null.</returns>
|
/// <returns>Контейнер со счётчиками.</returns>
|
||||||
public async Task<ContainerDto?> GetAsync(string containerId, CancellationToken ct)
|
/// <exception cref="NotFoundException">Контейнер не найден.</exception>
|
||||||
|
public async Task<ContainerDto> GetAsync(string containerId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ContainerDto? container = await store.GetContainerAsync(containerId, ct);
|
ContainerDto container = await store.GetContainerAsync(containerId, ct)
|
||||||
if (container is null)
|
?? throw new NotFoundException(ContainerEntityName, containerId);
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
IReadOnlyDictionary<string, CardColumnCountDto> counts = await store.CountCardsByColAsync(ct);
|
IReadOnlyDictionary<string, CardColumnCountDto> counts = await store.CountCardsByColAsync(ct);
|
||||||
ContainerCountsDto containerCounts = counts.TryGetValue(container.Id, out CardColumnCountDto? count)
|
ContainerCountsDto containerCounts = counts.TryGetValue(container.Id, out CardColumnCountDto? count)
|
||||||
@@ -107,17 +109,15 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="containerId">Id контейнера.</param>
|
/// <param name="containerId">Id контейнера.</param>
|
||||||
/// <param name="patch">Изменения; null-поле означает «не менять».</param>
|
/// <param name="patch">Изменения; null-поле означает «не менять».</param>
|
||||||
/// <returns>Контейнер после патча; null — контейнера нет (404 «Контейнер не найден»).</returns>
|
/// <returns>Контейнер после патча.</returns>
|
||||||
public async Task<ContainerDto?> PatchAsync(
|
/// <exception cref="NotFoundException">Контейнер не найден.</exception>
|
||||||
|
public async Task<ContainerDto> PatchAsync(
|
||||||
string containerId,
|
string containerId,
|
||||||
ContainerPatchDto patch,
|
ContainerPatchDto patch,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
ContainerDto? current = await store.GetContainerAsync(containerId, ct);
|
ContainerDto current = await store.GetContainerAsync(containerId, ct)
|
||||||
if (current is null)
|
?? throw new NotFoundException(ContainerEntityName, containerId);
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
ContainerDto updated = ApplyPatch(current, patch);
|
ContainerDto updated = ApplyPatch(current, patch);
|
||||||
await store.UpdateContainerAsync(updated, ct);
|
await store.UpdateContainerAsync(updated, ct);
|
||||||
@@ -128,8 +128,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
|
|||||||
/// Принимает ИИ-предложение
|
/// Принимает ИИ-предложение
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="containerId">Id контейнера-предложения.</param>
|
/// <param name="containerId">Id контейнера-предложения.</param>
|
||||||
/// <returns>Контейнер после принятия; null — контейнера нет (404).</returns>
|
/// <returns>Контейнер после принятия.</returns>
|
||||||
public Task<ContainerDto?> AcceptSuggestedAsync(string containerId, CancellationToken ct)
|
/// <exception cref="NotFoundException">Контейнер не найден.</exception>
|
||||||
|
public Task<ContainerDto> AcceptSuggestedAsync(string containerId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return PatchAsync(containerId, new ContainerPatchDto(
|
return PatchAsync(containerId, new ContainerPatchDto(
|
||||||
Name: null,
|
Name: null,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace Deal.SharedKernel.Errors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Коды ошибок Deal для логов и ответов клиенту.
|
||||||
|
/// </summary>
|
||||||
|
public static class DealErrorCodes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Запрошенный объект не найден.
|
||||||
|
/// </summary>
|
||||||
|
public const string NotFound = "not_found";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Некорректные данные запроса.
|
||||||
|
/// </summary>
|
||||||
|
public const string Validation = "validation_error";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Конфликт состояния.
|
||||||
|
/// </summary>
|
||||||
|
public const string Conflict = "conflict";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Внешний сервис недоступен.
|
||||||
|
/// </summary>
|
||||||
|
public const string Unavailable = "unavailable";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Непредвиденная внутренняя ошибка.
|
||||||
|
/// </summary>
|
||||||
|
public const string Internal = "internal_error";
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Deal.SharedKernel.Resources;
|
||||||
|
|
||||||
|
namespace Deal.SharedKernel.Errors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// База доменных исключений Deal: код ошибки и текст из ресурсов.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class DealException : Exception
|
||||||
|
{
|
||||||
|
protected DealException(
|
||||||
|
string errorCode,
|
||||||
|
string messageKey,
|
||||||
|
params object?[] messageArgs)
|
||||||
|
: base(ErrorResources.Format(messageKey, messageArgs))
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(errorCode);
|
||||||
|
ErrorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected DealException(
|
||||||
|
string errorCode,
|
||||||
|
Exception innerException,
|
||||||
|
string messageKey,
|
||||||
|
params object?[] messageArgs)
|
||||||
|
: base(ErrorResources.Format(messageKey, messageArgs), innerException)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(errorCode);
|
||||||
|
ErrorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Код ошибки для логов и ответов клиенту.
|
||||||
|
/// </summary>
|
||||||
|
public string ErrorCode { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Deal.SharedKernel.Resources;
|
||||||
|
|
||||||
|
namespace Deal.SharedKernel.Errors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запрошенный объект не найден.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NotFoundException : DealException
|
||||||
|
{
|
||||||
|
public NotFoundException(string entityName)
|
||||||
|
: base(DealErrorCodes.NotFound, ErrorResourceKeys.NotFoundEntity, entityName)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public NotFoundException(string entityName, string entityId)
|
||||||
|
: base(DealErrorCodes.NotFound, ErrorResourceKeys.NotFoundEntityWithId, entityName, entityId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Deal.SharedKernel.Resources;
|
||||||
|
|
||||||
|
namespace Deal.SharedKernel.Errors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Внешний сервис недоступен.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServiceUnavailableException : DealException
|
||||||
|
{
|
||||||
|
public ServiceUnavailableException(string serviceName)
|
||||||
|
: base(DealErrorCodes.Unavailable, ErrorResourceKeys.ServiceUnavailable, serviceName)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ServiceUnavailableException(string serviceName, Exception innerException)
|
||||||
|
: base(DealErrorCodes.Unavailable, innerException, ErrorResourceKeys.ServiceUnavailable, serviceName)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Deal.SharedKernel.Resources;
|
||||||
|
|
||||||
|
namespace Deal.SharedKernel.Errors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Некорректные данные запроса.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ValidationException : DealException
|
||||||
|
{
|
||||||
|
public ValidationException(string messageKey, params object?[] messageArgs)
|
||||||
|
: base(DealErrorCodes.Validation, messageKey, messageArgs)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<data name="UnexpectedError" xml:space="preserve">
|
||||||
|
<value>Внутренняя ошибка сервиса. Обратитесь в поддержку.</value>
|
||||||
|
</data>
|
||||||
|
<data name="NotFoundEntity" xml:space="preserve">
|
||||||
|
<value>Объект не найден: {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="NotFoundEntityWithId" xml:space="preserve">
|
||||||
|
<value>Объект не найден: {0} (id: {1}).</value>
|
||||||
|
</data>
|
||||||
|
<data name="ValidationFailed" xml:space="preserve">
|
||||||
|
<value>Некорректные данные запроса: {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ConflictState" xml:space="preserve">
|
||||||
|
<value>Конфликт состояния: {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ServiceUnavailable" xml:space="preserve">
|
||||||
|
<value>Сервис «{0}» временно недоступен.</value>
|
||||||
|
</data>
|
||||||
|
</root>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Deal.SharedKernel.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключи текстов ошибок Deal в ресурсах ErrorMessages.resx.
|
||||||
|
/// </summary>
|
||||||
|
public static class ErrorResourceKeys
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Общая непредвиденная внутренняя ошибка.
|
||||||
|
/// </summary>
|
||||||
|
public const string UnexpectedError = "UnexpectedError";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Объект не найден (без идентификатора).
|
||||||
|
/// </summary>
|
||||||
|
public const string NotFoundEntity = "NotFoundEntity";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Объект не найден (с идентификатором).
|
||||||
|
/// </summary>
|
||||||
|
public const string NotFoundEntityWithId = "NotFoundEntityWithId";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Некорректные данные запроса.
|
||||||
|
/// </summary>
|
||||||
|
public const string ValidationFailed = "ValidationFailed";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Конфликт состояния.
|
||||||
|
/// </summary>
|
||||||
|
public const string ConflictState = "ConflictState";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Внешний сервис недоступен.
|
||||||
|
/// </summary>
|
||||||
|
public const string ServiceUnavailable = "ServiceUnavailable";
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Resources;
|
||||||
|
|
||||||
|
namespace Deal.SharedKernel.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Тексты ошибок Deal из ресурсов ErrorMessages.resx.
|
||||||
|
/// </summary>
|
||||||
|
public static class ErrorResources
|
||||||
|
{
|
||||||
|
private const string ResourceBaseName = "Deal.SharedKernel.Resources.ErrorMessages";
|
||||||
|
|
||||||
|
private static readonly ResourceManager Manager = new(ResourceBaseName, typeof(ErrorResources).Assembly);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Форматированный текст по ключу ресурса с подстановкой аргументов.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="key">Ключ ресурса (см. <see cref="ErrorResourceKeys"/>).</param>
|
||||||
|
/// <param name="args">Аргументы шаблона.</param>
|
||||||
|
/// <returns>Текст ресурса; неизвестный ключ возвращается как есть.</returns>
|
||||||
|
public static string Format(string key, params object?[] args)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||||
|
string? template = Manager.GetString(key, CultureInfo.CurrentUICulture);
|
||||||
|
if (string.IsNullOrEmpty(template))
|
||||||
|
{
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return args.Length == 0
|
||||||
|
? template
|
||||||
|
: string.Format(CultureInfo.CurrentUICulture, template, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Deal.Api.Middleware;
|
||||||
|
using Deal.SharedKernel.Errors;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
namespace Deal.Tests.Unit.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Тесты обработчика необработанных исключений HTTP.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DealExceptionHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task NotFound_MapsTo404WithCodeAndRussianDetail()
|
||||||
|
{
|
||||||
|
DefaultHttpContext context = CreateContext();
|
||||||
|
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
|
||||||
|
|
||||||
|
bool handled = await handler.TryHandleAsync(
|
||||||
|
context,
|
||||||
|
new NotFoundException("Карточка", "c_1"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(handled);
|
||||||
|
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
|
||||||
|
(string detail, string code) = await ReadBodyAsync(context);
|
||||||
|
Assert.Equal(DealErrorCodes.NotFound, code);
|
||||||
|
Assert.Contains("Карточка", detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unavailable_MapsTo503()
|
||||||
|
{
|
||||||
|
DefaultHttpContext context = CreateContext();
|
||||||
|
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
|
||||||
|
|
||||||
|
await handler.TryHandleAsync(
|
||||||
|
context,
|
||||||
|
new ServiceUnavailableException("ИИ"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UnexpectedException_MapsToGeneric500WithoutStackOrDetails()
|
||||||
|
{
|
||||||
|
DefaultHttpContext context = CreateContext();
|
||||||
|
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
|
||||||
|
|
||||||
|
bool handled = await handler.TryHandleAsync(
|
||||||
|
context,
|
||||||
|
new InvalidOperationException("секретная внутренняя деталь"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(handled);
|
||||||
|
Assert.Equal(StatusCodes.Status500InternalServerError, context.Response.StatusCode);
|
||||||
|
(string detail, string code) = await ReadBodyAsync(context);
|
||||||
|
Assert.Equal(DealErrorCodes.Internal, code);
|
||||||
|
Assert.DoesNotContain("секретная внутренняя деталь", detail);
|
||||||
|
Assert.DoesNotContain("at ", detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DefaultHttpContext CreateContext()
|
||||||
|
{
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Response.Body = new MemoryStream();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(string Detail, string Code)> ReadBodyAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||||
|
using var reader = new StreamReader(context.Response.Body);
|
||||||
|
string json = await reader.ReadToEndAsync();
|
||||||
|
using JsonDocument document = JsonDocument.Parse(json);
|
||||||
|
return (
|
||||||
|
document.RootElement.GetProperty("detail").GetString() ?? string.Empty,
|
||||||
|
document.RootElement.GetProperty("code").GetString() ?? string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
using Deal.Contracts.Integrations.Models;
|
using Deal.Contracts.Integrations.Models;
|
||||||
using Deal.Tests.Unit.Support;
|
|
||||||
using Deal.Modules.Cards.Application.Sources;
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Deal.Modules.Kanban.Application.Services;
|
using Deal.Modules.Kanban.Application.Services;
|
||||||
|
using Deal.SharedKernel.Errors;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Contracts;
|
namespace Deal.Tests.Unit.Contracts;
|
||||||
|
|
||||||
@@ -343,13 +344,13 @@ public sealed class CardsServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Trash_CardMissing_ReturnsNull()
|
public async Task Trash_CardMissing_ThrowsNotFound()
|
||||||
{
|
{
|
||||||
(CardsService service, _, _, TestMlClient ml) = Create();
|
(CardsService service, _, _, TestMlClient ml) = Create();
|
||||||
|
|
||||||
CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None);
|
await Assert.ThrowsAsync<NotFoundException>(
|
||||||
|
() => service.TrashCardAsync("l_ghost", CancellationToken.None));
|
||||||
|
|
||||||
Assert.Null(result); // эндпоинт отвечает 404 «Карточка не найдена»
|
|
||||||
Assert.Empty(ml.Pushed);
|
Assert.Empty(ml.Pushed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,13 +426,12 @@ public sealed class CardsServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Restore_CardMissing_ReturnsNull()
|
public async Task Restore_CardMissing_ThrowsNotFound()
|
||||||
{
|
{
|
||||||
(CardsService service, _, _, _) = Create();
|
(CardsService service, _, _, _) = Create();
|
||||||
|
|
||||||
string? back = await service.RestoreCardAsync("l_ghost", CancellationToken.None);
|
await Assert.ThrowsAsync<NotFoundException>(
|
||||||
|
() => service.RestoreCardAsync("l_ghost", CancellationToken.None));
|
||||||
Assert.Null(back); // эндпоинт отвечает 404 «Карточка не найдена»
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: Вызов нижестоящего обработчика.
|
||||||
|
|||||||
Reference in New Issue
Block a user