Перевести «не найдено» ML/Pipeline на NotFoundException

MlReviewService.ApplyAsync (исходное сообщение) и PipelineProcessingService.ReturnAsync (запись отсева) бросают NotFoundException; эндпоинты без 404-проверок.
This commit is contained in:
2026-09-13 18:44:33 +03:00
parent f93fb0fdd3
commit 891a894aed
6 changed files with 29 additions and 37 deletions
+1 -8
View File
@@ -37,8 +37,6 @@ public static class MlEndpoints
private const string EnterTextDetail = "Введите текст"; private const string EnterTextDetail = "Введите текст";
private const string MessageNotFoundDetail = "Исходное сообщение не найдено";
/// <summary> /// <summary>
/// Регистрирует группу /api/ml /// Регистрирует группу /api/ml
/// </summary> /// </summary>
@@ -141,12 +139,7 @@ public static class MlEndpoints
} }
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>(); MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
MlApplyResult? result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct); MlApplyResult result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct);
if (result is null)
{
return EndpointResults.NotFound(MessageNotFoundDetail);
}
if (result.Error is not null) if (result.Error is not null)
{ {
return EndpointResults.BadRequest(result.Error); return EndpointResults.BadRequest(result.Error);
@@ -33,8 +33,6 @@ public static class PipelineEndpoints
// Путь возврата записи отсева в обработку (POST). // Путь возврата записи отсева в обработку (POST).
private const string RejectedReturnPath = "/rejected/{rejId}/return"; private const string RejectedReturnPath = "/rejected/{rejId}/return";
private const string RejectedNotFoundDetail = "Запись не найдена";
private const int DefaultPageSize = 100; private const int DefaultPageSize = 100;
/// <summary> /// <summary>
@@ -140,11 +138,7 @@ public static class PipelineEndpoints
} }
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>(); PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
RejectReturnResultDto? result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct); RejectReturnResultDto result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct);
if (result is null)
{
return EndpointResults.NotFound(RejectedNotFoundDetail);
}
return result.Error is not null return result.Error is not null
? EndpointResults.BadRequest(result.Error) ? EndpointResults.BadRequest(result.Error)
@@ -8,6 +8,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Pipeline.Application.Services; namespace Deal.Modules.Pipeline.Application.Services;
@@ -34,6 +35,9 @@ public sealed class MlReviewService(
// Размер одного чтения из очереди/отсева при объединении кандидатов. // Размер одного чтения из очереди/отсева при объединении кандидатов.
private const int MaxScan = 500; private const int MaxScan = 500;
// Имя сущности для текста ошибки «не найдено».
private const string MessageEntityName = "Исходное сообщение";
private const int TextPreviewLength = 600; private const int TextPreviewLength = 600;
/// <summary> /// <summary>
@@ -157,8 +161,9 @@ public sealed class MlReviewService(
/// <param name="dialogId">Оригинал источника (OriginRef) записи.</param> /// <param name="dialogId">Оригинал источника (OriginRef) записи.</param>
/// <param name="msgId">Внешний id записи в источнике.</param> /// <param name="msgId">Внешний id записи в источнике.</param>
/// <param name="action">Действие: <c>skip</c> | <c>spam</c> | <c>board:&lt;id&gt;</c>.</param> /// <param name="action">Действие: <c>skip</c> | <c>spam</c> | <c>board:&lt;id&gt;</c>.</param>
/// <returns>Результат решения; null — исходная запись не найдена (404-семантика эндпоинта).</returns> /// <returns>Результат решения.</returns>
public async Task<MlApplyResult?> ApplyAsync( /// <exception cref="NotFoundException">Исходная запись не найдена.</exception>
public async Task<MlApplyResult> ApplyAsync(
string dialogId, string dialogId,
long msgId, long msgId,
string? action, string? action,
@@ -168,17 +173,14 @@ public sealed class MlReviewService(
string dialog = (dialogId ?? string.Empty).Trim(); string dialog = (dialogId ?? string.Empty).Trim();
string externalId = msgId.ToString(CultureInfo.InvariantCulture); string externalId = msgId.ToString(CultureInfo.InvariantCulture);
SourceRef? source = await ResolveSourceAsync(dialog, externalId, ct); SourceRef source = await ResolveSourceAsync(dialog, externalId, ct)
if (source is null) ?? throw new NotFoundException(MessageEntityName, externalId);
{
return null; // 404: исходная запись не найдена
}
CardDto? card = await cardStore.GetCardBySourceAsync(source, ct); CardDto? card = await cardStore.GetCardBySourceAsync(source, ct);
string? text = await FindTextAsync(dialog, externalId, card, ct); string? text = await FindTextAsync(dialog, externalId, card, ct);
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
return null; // 404: исходная запись не найдена throw new NotFoundException(MessageEntityName, externalId);
} }
if (normalized == ActionSkip) if (normalized == ActionSkip)
@@ -4,6 +4,7 @@ using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Parse;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Pipeline.Application.Services; namespace Deal.Modules.Pipeline.Application.Services;
@@ -48,6 +49,9 @@ public sealed class PipelineProcessingService(
private const string DuplicateSource = "dup"; private const string DuplicateSource = "dup";
// Имя сущности для текста ошибки «не найдено».
private const string RejectedEntityName = "Запись отсева";
private static readonly HashSet<string> SpamStages = new(StringComparer.Ordinal) private static readonly HashSet<string> SpamStages = new(StringComparer.Ordinal)
{ {
"spam_ml", "spam_ml",
@@ -154,17 +158,15 @@ public sealed class PipelineProcessingService(
/// </summary> /// </summary>
/// <param name="rejectedId">Id записи отсева (<c>r_...</c>).</param> /// <param name="rejectedId">Id записи отсева (<c>r_...</c>).</param>
/// <param name="reason">Причина возврата (trim, ≤500; пишется на запись для аудита).</param> /// <param name="reason">Причина возврата (trim, ≤500; пишется на запись для аудита).</param>
/// <returns>null — записи нет (404); иначе результат: Error (400) либо {id, returned:true, returnedAt}.</returns> /// <returns>Результат: Error (400) либо {id, returned:true, returnedAt}.</returns>
public async Task<RejectReturnResultDto?> ReturnAsync( /// <exception cref="NotFoundException">Запись отсева не найдена.</exception>
public async Task<RejectReturnResultDto> ReturnAsync(
string rejectedId, string rejectedId,
string reason, string reason,
CancellationToken ct) CancellationToken ct)
{ {
RejectedItemDto? row = await store.GetAsync(rejectedId, ct); RejectedItemDto row = await store.GetAsync(rejectedId, ct)
if (row is null) ?? throw new NotFoundException(RejectedEntityName, rejectedId);
{
return null;
}
if (row.Returned) if (row.Returned)
{ {
@@ -4,6 +4,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.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;
using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Support;
@@ -241,12 +242,11 @@ public sealed class MlReviewServiceTests
} }
[Fact] [Fact]
public async Task Apply_MessageNotFound_ReturnsNull() public async Task Apply_MessageNotFound_ThrowsNotFound()
{ {
MlReviewService service = Create(new TestPipelineStore(), new TestKanjStore(), new TestMlClient(), out _); MlReviewService service = Create(new TestPipelineStore(), new TestKanjStore(), new TestMlClient(), out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None));
Assert.Null(result);
} }
} }
@@ -2,6 +2,7 @@ using System.Globalization;
using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Support;
@@ -165,13 +166,13 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ReturnAsync_RecordNotFound_ReturnsNull() public async Task ReturnAsync_RecordNotFound_ThrowsNotFound()
{ {
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create(); (PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
RejectReturnResultDto? result = await service.ReturnAsync("r_missing", string.Empty, CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.ReturnAsync("r_missing", string.Empty, CancellationToken.None));
Assert.Null(result); // эндпоинт ответит 404 «Запись не найдена» (Ruling 10)
Assert.Empty(store.Queue); Assert.Empty(store.Queue);
Assert.Empty(ml.Pushed); Assert.Empty(ml.Pushed);
} }