Files
Deal/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs
T
Rustam Khalimov 410194b0cb Разбить Infrastructure и корень Deal.Api по назначению
Integrations -> Abstractions/Exceptions/Extensions/Models/Options/
Services (включая Storage); Persistence-конфигурации -> Configurations;
корень Deal.Api (оркестратор/планировщики/DTO) -> Services/Dtos.
namespace приведён к путям, using добавлены/дедуплицированы, FQN
обновлены.
2026-09-11 13:20:10 +03:00

545 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Telegram.Application;
using Deal.Api.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты /api/discovery: задачи поиска, кандидаты, чёрный список, лог, генерация ключей (Ruling 11, api-map §3.8).
/// </summary>
/// <remarks>
/// 13 эндпоинтов 1:1 с <c>backend/app/routers/discovery_routes.py</c> (prefix /api/discovery): tasks
/// (list/create/patch/delete/start/pause), generate-keywords (мягкая ошибка HTTP 200 {keywords: [], error},
/// Ruling 11), candidates (статус-фильтр), join/reject (ручные, вне квот воркера), blacklist, log. Тела ответов —
/// DTO модуля Discovery (camelCase, §4.8 L353355) и {items: [...]} для списков (конвенция api-map §1);
/// 404-семантика сервисов (null/KeyError python) — «Задача не найдена»/«Кандидат не найден»,
/// 400-семантика — <see cref="DiscoveryValidationException"/> (ValueError python) с текстом причины 1:1.
/// Ручной join — как worker-авто-join (Task 18): RPC Join через <see cref="ITelegramGateway"/> → строка каталога
/// Dialogs (монитор on) + зеркало через <see cref="DialogsService.AddDiscoveredMonitoredAsync"/> → фоновый первый
/// разбор (<see cref="TelegramBackfillScheduler"/>, python-_spawn) → снятие чёрного списка → mark_joined(auto:false);
/// ошибка Telegram → 400 с текстом причины. Все эндпоинты требуют сессию: 401 {detail} (Ruling 10); сервисы
/// резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped — TenantDbContext схемы тенанта, паттерн SettingsEndpoints).
/// </remarks>
public static class DiscoveryEndpoints
{
// Префикс группы /api/discovery (python: router prefix, discovery_routes.py L26).
private const string DiscoveryGroupPrefix = "/api/discovery";
// OpenAPI-тег группы (в прототипе роутер discovery — discovery_routes.py).
private const string DiscoveryOpenApiTag = "discovery";
// Путь списка задач (GET).
private const string TasksPath = "/tasks";
// Путь одной задачи: обновление (PATCH) / удаление (DELETE).
private const string TaskPath = "/tasks/{task_id}";
// Путь запуска поиска (POST).
private const string TaskStartPath = "/tasks/{task_id}/start";
// Путь паузы поиска (POST).
private const string TaskPausePath = "/tasks/{task_id}/pause";
// Путь ИИ-генерации ключевых слов по описанию задачи (POST).
private const string TaskGenerateKeywordsPath = "/tasks/{task_id}/generate-keywords";
// Путь списка кандидатов задачи со статус-фильтром (GET).
private const string TaskCandidatesPath = "/tasks/{task_id}/candidates";
// Путь ручного вступления в кандидата (POST).
private const string CandidateJoinPath = "/candidates/{dialog_id}/join";
// Путь отклонения кандидата в чёрный список (POST).
private const string CandidateRejectPath = "/candidates/{dialog_id}/reject";
// Путь чёрного списка (GET) и снятия записи (DELETE).
private const string BlacklistPath = "/blacklist";
// Путь записи чёрного списка (DELETE).
private const string BlacklistItemPath = "/blacklist/{dialog_id}";
// Путь лога задачи (GET).
private const string TaskLogPath = "/tasks/{task_id}/log";
// 404 create/start/patch/candidates/log: задачи нет (python _task_or_404 L8387).
private const string TaskNotFoundDetail = "Задача не найдена";
// 404 join/reject: кандидата нет (python _candidate_or_404 L9094).
private const string CandidateNotFoundDetail = "Кандидат не найден";
// 400 join: уже вступили (python L235237).
private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
// 400 reject: источник уже вступили (python L256258).
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
// 400 join: ошибка Telegram при вступлении (python L240242, текст с @username).
private const string JoinFailedFormat = "Не удалось вступить в @{0}: {1}";
// Причина отклонения вручную для чёрного списка/лога (python L260: reason="отклонено вручную").
private const string ManualRejectReason = "отклонено вручную";
// Мягкая ошибка generate-keywords: ИИ выключен (python _ai_unavailable_reason L99100).
private const string AiDisabledDetail = "ИИ выключен в настройках (aiEnabled)";
// Мягкая ошибка generate-keywords: описания нет (python L201202).
private const string NoDescriptionDetail = "У задачи нет описания — по нему генерируются ключи";
// Страховочный потолок числа сгенерированных ключей (python _KEYWORDS_LIMIT L31: промпт просит 10–16).
private const int KeywordsLimit = 30;
// Потолок длины одного ключа (python _KEYWORD_LENGTH_LIMIT L33: короткие фразы для поиска Telegram).
private const int KeywordLengthLimit = 60;
/// <summary>
/// Регистрирует группу /api/discovery: 13 эндпоинтов (tasks + generate-keywords + candidates + join/reject + blacklist + log).
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapDiscoveryEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(DiscoveryGroupPrefix).WithTags(DiscoveryOpenApiTag);
group.MapGet(TasksPath, ListTasksAsync);
group.MapPost(TasksPath, CreateTaskAsync);
group.MapPatch(TaskPath, PatchTaskAsync);
group.MapDelete(TaskPath, DeleteTaskAsync);
group.MapPost(TaskStartPath, StartTaskAsync);
group.MapPost(TaskPausePath, PauseTaskAsync);
group.MapPost(TaskGenerateKeywordsPath, GenerateKeywordsAsync);
group.MapGet(TaskCandidatesPath, ListCandidatesAsync);
group.MapPost(CandidateJoinPath, JoinCandidateAsync);
group.MapPost(CandidateRejectPath, RejectCandidateAsync);
group.MapGet(BlacklistPath, ListBlacklistAsync);
group.MapDelete(BlacklistItemPath, RemoveBlacklistAsync);
group.MapGet(TaskLogPath, TaskLogAsync);
return app;
}
// GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141143).
private static async Task<IResult> ListTasksAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
IReadOnlyList<DiscoveryTaskDto> items = await tasks.ListAsync(ct);
return Results.Ok(new { items });
}
// POST /api/discovery/tasks: создать задачу поиска (create_task L146151; дефолты — в сервисе).
private static async Task<IResult> CreateTaskAsync(DiscoveryTaskCreateBody body, HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
try
{
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto task = await tasks.CreateAsync(ToDraft(body), ct);
return Results.Ok(task);
}
catch (DiscoveryValidationException exception)
{
return EndpointResults.BadRequest(exception.Message);
}
}
// PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154161; 404/400).
private static async Task<IResult> PatchTaskAsync(string task_id, DiscoveryTaskPatchBody body, HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
try
{
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.PatchAsync(task_id, ToPatch(body), ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
}
catch (DiscoveryValidationException exception)
{
return EndpointResults.BadRequest(exception.Message);
}
}
// DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164168).
private static async Task<IResult> DeleteTaskAsync(string task_id, HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
bool deleted = await tasks.DeleteAsync(task_id, ct);
return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail);
}
// POST /api/discovery/tasks/{task_id}/start: запуск поиска (start_task L171179; пустые ключи → 400).
private static async Task<IResult> StartTaskAsync(string task_id, HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
try
{
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.StartAsync(task_id, ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
}
catch (DiscoveryValidationException exception)
{
return EndpointResults.BadRequest(exception.Message);
}
}
// POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181187).
private static async Task<IResult> PauseTaskAsync(string task_id, HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.PauseAsync(task_id, ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
}
// POST /api/discovery/tasks/{task_id}/generate-keywords: ИИ-ключи по описанию задачи
// (generate_keywords L189211). ИИ выключен/недоступен/нет описания → HTTP 200 {keywords: [], error}.
// Очистка ключей ответа — CleanKeywords (python _clean_keywords L111128: ≤30, ≤60
// символов, дедуп casefold); описание режет до 4000 сам адаптер (GrpcAiTools.MaxDescriptionCodePoints).
// Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины.
private static async Task<IResult> GenerateKeywordsAsync(string task_id, HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
ISettingsStore settings = context.RequestServices.GetRequiredService<ISettingsStore>();
if (!await ReadAiEnabledAsync(settings, ct))
{
return Results.Ok(new { keywords = Array.Empty<string>(), error = AiDisabledDetail });
}
string description = (task.Description ?? string.Empty).Trim();
if (description.Length == 0)
{
return Results.Ok(new { keywords = Array.Empty<string>(), error = NoDescriptionDetail });
}
try
{
IAiTools aiTools = context.RequestServices.GetRequiredService<IAiTools>();
AiGenerateKeywordsResultDto result = await aiTools.GenerateKeywordsAsync(description, ct);
if (result.Ok)
{
return Results.Ok(new { keywords = CleanKeywords(result.Keywords) });
}
// Недоступность провайдера/сервиса — мягкая ошибка для UI (Ruling 11), HTTP 200.
return Results.Ok(new { keywords = Array.Empty<string>(), error = result.Error ?? ServiceUnavailableText });
}
catch (NotSupportedException exception)
{
// Локальный режим: ai-service не подключён — инструменты недоступны (LocalAiTools, Task 15).
return Results.Ok(new { keywords = Array.Empty<string>(), error = exception.Message });
}
}
// GET /api/discovery/tasks/{task_id}/candidates?status=: кандидаты задачи с фильтром
// new|review|joined|rejected (list_candidates L216224; невалидный статус — пустой список).
private static async Task<IResult> ListCandidatesAsync(string task_id, string? status, HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
IReadOnlyList<DiscoveryCandidateDto> items = await candidates.ListAsync(task_id, status, ct);
return Results.Ok(new { items });
}
// POST /api/discovery/candidates/{dialog_id}/join: ручное вступление вне квот (join_candidate L226251).
// RPC Join → строка каталога Dialogs (монитор on) + зеркало → фоновый первый разбор → снятие чёрного списка →
// mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины.
private static async Task<IResult> JoinCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct);
if (row is null)
{
return EndpointResults.NotFound(CandidateNotFoundDetail);
}
if (row.Status == DiscoveryCandidateStatuses.Joined)
{
return EndpointResults.BadRequest(AlreadyJoinedDetail);
}
string username = (row.Username ?? string.Empty).Trim().TrimStart('@');
try
{
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
await gateway.JoinAsync(username, ct);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
string reason = TelegramEndpoints.GatewayErrorText(exception);
return EndpointResults.BadRequest(string.Format(JoinFailedFormat, username, reason));
}
// Источник в каталоге (монитор on, backfilled=false) + монитор-зеркало telegram-service
// (python add_dialog_monitored L242; решение T18: локальную строку пишет Api-слой).
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
await dialogs.AddDiscoveredMonitoredAsync(dialog_id, row.Name, username, row.Kind, row.Hue, ct);
// Догон последних сообщений — в фоне: join из UI не должен висеть на паузах backfill
// (python _spawn(_backfill_quiet) L244245; источник уже в каталоге и мониторится).
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfill(dialog_id);
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
await blacklist.RemoveAsync(dialog_id, ct);
try
{
DiscoveryCandidateDto? joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct);
return joined is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(joined);
}
catch (DiscoveryValidationException exception)
{
return EndpointResults.BadRequest(exception.Message);
}
}
// POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список
// (reject_candidate L253264; уже вступившего — нельзя, 400).
private static async Task<IResult> RejectCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct);
if (row is null)
{
return EndpointResults.NotFound(CandidateNotFoundDetail);
}
if (row.Status == DiscoveryCandidateStatuses.Joined)
{
return EndpointResults.BadRequest(JoinedRejectDetail);
}
try
{
DiscoveryCandidateDto? rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct);
return rejected is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(rejected);
}
catch (DiscoveryValidationException exception)
{
return EndpointResults.BadRequest(exception.Message);
}
}
// GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269271).
private static async Task<IResult> ListBlacklistAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
IReadOnlyList<DiscoveryBlacklistDto> items = await blacklist.ListAsync(ct);
return Results.Ok(new { items });
}
// DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274277).
private static async Task<IResult> RemoveBlacklistAsync(string dialog_id, HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
await blacklist.RemoveAsync(dialog_id, ct);
return Results.Ok(new { ok = true });
}
// GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282285), события от новых к старым.
private static async Task<IResult> TaskLogAsync(string task_id, HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
DiscoveryLogService log = context.RequestServices.GetRequiredService<DiscoveryLogService>();
IReadOnlyList<DiscoveryLogDto> items = await log.TaskLogAsync(task_id, ct);
return Results.Ok(new { items });
}
/// <summary>
/// Ключи из ответа ИИ: строки без пустых/длинных и повторов (python _clean_keywords L111128).
/// </summary>
/// <remarks>Повтор считается по <c>casefold</c> python: здесь — регистронезависимое сравнение
/// (RU/EN-ключи; StringComparer.OrdinalIgnoreCase). Потолок списка — <see cref="KeywordsLimit"/>.</remarks>
/// <param name="raw">Сырые ключи ответа модели (null — пусто).</param>
/// <returns>Очищенные ключи (не более 30, каждый ≤60 символов).</returns>
public static IReadOnlyList<string> CleanKeywords(IEnumerable<string>? raw)
{
if (raw is null)
{
return Array.Empty<string>();
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var outList = new List<string>();
foreach (string? item in raw)
{
if (item is null)
{
continue;
}
string keyword = item.Trim();
if (keyword.Length == 0 || keyword.Length > KeywordLengthLimit || !seen.Add(keyword))
{
continue;
}
outList.Add(keyword);
if (outList.Count >= KeywordsLimit)
{
break;
}
}
return outList;
}
// Фолбэк-текст недоступного ИИ, если адаптер причину не вернул (мягкая ошибка, Ruling 11).
private const string ServiceUnavailableText = "ИИ недоступен — повторите попытку через несколько секунд";
// Читает настройку aiEnabled (KV; отсутствие строки — дефолт SettingsDefaults).
// settings: KV-хранилище настроек тенанта.
// ct: Токен отмены.
// Возвращает: True — ИИ включён (ветки выключателя отрабатывает вызывающий, Ruling 10/11).
private static async Task<bool> ReadAiEnabledAsync(ISettingsStore settings, CancellationToken ct)
{
SettingValue? row = await settings.GetAsync(SettingsKeys.AiEnabled, ct);
if (row is null)
{
return SettingsDefaults.AiEnabled;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
return document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False
? document.RootElement.GetBoolean()
: SettingsDefaults.AiEnabled;
}
catch (JsonException)
{
return SettingsDefaults.AiEnabled;
}
}
// Маппит тело создания в черновик сервиса (значения пограничные передаются как есть — дефолты в сервисе).
// body: Тело запроса (wire-поля camelCase).
// Возвращает: Черновик создания задачи (DiscoveryTaskDraft).
private static DiscoveryTaskDraft ToDraft(DiscoveryTaskCreateBody body)
{
return new DiscoveryTaskDraft
{
Name = body.Name,
Description = body.Description ?? string.Empty,
Keywords = body.Keywords,
MinSubscribers = body.MinSubscribers,
Lang = body.Lang,
Threshold = body.Threshold,
SampleSize = body.SampleSize,
PlanJoins = body.PlanJoins,
AutoJoin = body.AutoJoin,
};
}
// Маппит тело патча в сервисный патч (не-null значения; как python model_dump(exclude_none=True)).
// body: Тело запроса (wire-поля camelCase).
// Возвращает: Патч задачи (DiscoveryTaskPatch).
private static DiscoveryTaskPatch ToPatch(DiscoveryTaskPatchBody body)
{
return new DiscoveryTaskPatch
{
Name = body.Name,
Description = body.Description,
Keywords = body.Keywords,
MinSubscribers = body.MinSubscribers,
Lang = body.Lang,
Threshold = body.Threshold,
SampleSize = body.SampleSize,
PlanJoins = body.PlanJoins,
AutoJoin = body.AutoJoin,
};
}
}