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.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; namespace Deal.Modules.Discovery.Application.Services; /// /// Сервис задач поиска Discovery — create/patch/delete/start/pause/advance/bump. /// public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings) { /// /// 400 create: пустое название после Trim. /// public const string NameRequiredDetail = "Укажите название задачи"; /// /// 400 start: у задачи нет ключевых слов. /// public const string NoKeywordsDetail = "Нет ключевых слов для поиска — добавьте их в задачу"; /// /// Все задачи, старые первыми. /// /// Задачи в порядке создания. public Task> ListAsync(CancellationToken ct) { return store.ListTasksAsync(ct); } /// /// Одна задача по id. /// /// Id задачи (dt_...). /// Задача или null (404 «Задача не найдена» у эндпоинта). public Task GetAsync(string taskId, CancellationToken ct) { return store.GetTaskAsync(taskId, ct); } /// /// Создаёт задачу поиска. /// /// Поля новой задачи (см. ). /// Созданная задача (полный DTO). /// Пустое имя / план вне 1..discJoinLimit / бюджет исчерпан. public async Task CreateAsync(DiscoveryTaskDraft draft, CancellationToken ct) { string name = (draft.Name ?? string.Empty).Trim(); if (name.Length == 0) { throw new DiscoveryValidationException(NameRequiredDetail); } int planJoins = draft.PlanJoins ?? 1; await planGuard.AssertPlanAsync(planJoins, ct).ConfigureAwait(false); await planGuard.AssertBudgetAsync(planJoins, excludeTaskId: null, ct).ConfigureAwait(false); string lang = draft.Lang is "ru" or "any" ? draft.Lang : "ru"; // Дефолты threshold/sampleSize — из типизированного снимка настроек (C30: один GetAllAsync на создание). TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); int threshold = Clamp( settingsSnapshot.GetInt(SettingsKeys.DiscEvalThreshold, SettingsDefaults.DiscEvalThreshold), min: 1, max: 100); int sampleSize = Math.Max(1, settingsSnapshot.GetInt(SettingsKeys.DiscEvalSample, SettingsDefaults.DiscEvalSample)); if (draft.Threshold is int requestedThreshold) { threshold = Clamp(requestedThreshold, min: 1, max: 100); } if (draft.SampleSize is int requestedSample) { sampleSize = Math.Max(1, requestedSample); } var row = new DiscoveryTaskRow { Id = DiscoveryIdPrefixes.NewTaskId(), Name = name, Description = draft.Description ?? string.Empty, Keywords = CleanKeywords(draft.Keywords), MinSubscribers = Math.Max(0, draft.MinSubscribers ?? 0), Lang = lang, Threshold = threshold, SampleSize = sampleSize, PlanJoins = planJoins, AutoJoin = draft.AutoJoin ?? false, }; await store.CreateTaskAsync(row, ct).ConfigureAwait(false); DiscoveryTaskDto created = await store.GetTaskAsync(row.Id, ct).ConfigureAwait(false) ?? throw new InvalidOperationException("create_task: строка не прочиталась после INSERT"); return created; } /// /// Обновляет поля задачи. /// /// Id задачи (dt_...). /// Изменяемые поля (null — не меняется). /// Обновлённая задача либо null (404 «Задача не найдена»). /// Новый план вне границ / бюджет исчерпан. public async Task PatchAsync( string taskId, DiscoveryTaskPatch patch, CancellationToken ct) { DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); if (current is null) { return null; } DiscoveryTaskPatch normalized = NormalizeTaskPatch(patch); if (normalized.PlanJoins is int newPlan) { await planGuard.AssertPlanAsync(newPlan, ct).ConfigureAwait(false); if (newPlan > current.PlanJoins) { await planGuard.AssertBudgetAsync(newPlan, excludeTaskId: taskId, ct).ConfigureAwait(false); } } if (!normalized.HasChanges()) { return current; } await store.PatchTaskAsync(taskId, normalized, ct).ConfigureAwait(false); return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); } /// /// Удаляет задачу вместе с кандидатами и логом. /// /// Id задачи (dt_...). /// True — задача удалена; false — строки нет (404 у эндпоинта). public async Task DeleteAsync(string taskId, CancellationToken ct) { DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); if (current is null) { return false; } return await store.DeleteTaskAsync(taskId, ct).ConfigureAwait(false); } /// /// Запускает поиск /// /// Id задачи (dt_...). /// Задача в running либо null (404). /// Ключевых слов нет. public async Task StartAsync(string taskId, CancellationToken ct) { DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); if (current is null) { return null; } if (current.Keywords.Count == 0) { throw new DiscoveryValidationException(NoKeywordsDetail); } bool resetProgress = DiscoveryTaskStatuses.IsFinished(current.Status); await store.SetTaskRunningAsync(taskId, resetProgress, ct).ConfigureAwait(false); return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); } /// /// Ставит задачу на паузу. /// /// Id задачи (dt_...). /// Задача в paused либо null (404). public async Task PauseAsync(string taskId, CancellationToken ct) { DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); if (current is null) { return null; } await store.SetTaskPausedAsync(taskId, ct).ConfigureAwait(false); return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); } /// /// Увеличивает счётчик прогресса задачи /// /// Id задачи (dt_...). /// Счётчик (found/evaluated/joined/rejected). /// Приращение (по умолчанию 1). /// True — счётчик увеличен; false — задачи нет. public Task BumpCounterAsync( string taskId, DiscoveryCounterField field, int n, CancellationToken ct) { return store.BumpTaskCounterAsync(taskId, field, Math.Max(0, n), ct); } /// /// Продвигает индекс поиска /// /// Id задачи (dt_...). /// True — задача обновлена; false — строки нет. public async Task AdvanceSearchAsync(string taskId, CancellationToken ct) { DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); if (current is null) { return false; } int nextIndex = current.SearchIdx + 1; bool searchDone = nextIndex >= current.Keywords.Count; return await store.AdvanceSearchAsync(taskId, nextIndex, searchDone, ct).ConfigureAwait(false); } private static DiscoveryTaskPatch NormalizeTaskPatch(DiscoveryTaskPatch patch) { int? threshold = patch.Threshold is int value ? Clamp(value, min: 1, max: 100) : null; int? sampleSize = patch.SampleSize is int sample ? Math.Max(1, sample) : null; int? minSubscribers = patch.MinSubscribers is int min ? Math.Max(0, min) : null; string? lang = patch.Lang is "ru" or "any" ? patch.Lang : patch.Lang is null ? null : "ru"; string? name = patch.Name is null ? null : patch.Name.Trim(); string? description = patch.Description; return new DiscoveryTaskPatch { Name = name, Description = description, Keywords = patch.Keywords is null ? null : CleanKeywords(patch.Keywords), MinSubscribers = minSubscribers, Lang = lang, Threshold = threshold, SampleSize = sampleSize, PlanJoins = patch.PlanJoins, AutoJoin = patch.AutoJoin, }; } private static IReadOnlyList CleanKeywords(IReadOnlyList? keywords) { return keywords?.Where(keyword => !string.IsNullOrWhiteSpace(keyword)) .Select(keyword => keyword.Trim()) .ToList() ?? new List(); } private static int Clamp( int value, int min, int max) { return Math.Max(min, Math.Min(max, value)); } }