Разбить модули Deal.Modules.* по назначению
Application проектов Discovery, Kanban, Pipeline, Settings, Tenants разделён на Abstractions/Exceptions/Extensions/Models/Registrars/Services; namespace приведён к путям, using потребителей мигрированы и дедуплицированы (169 файлов), cref/FQN обновлены.
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
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.Discovery.Application.Abstractions;
|
||||
using Deal.Modules.Discovery.Application.Exceptions;
|
||||
using Deal.Modules.Discovery.Application.Extensions;
|
||||
using Deal.Modules.Discovery.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Discovery.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Сервис задач поиска Discovery — create/patch/delete/start/pause/advance/bump (план Task 17, 1:1 discovery.py L234–381).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Чистый оркестратор поверх портов <see cref="IDiscoveryStore"/> (таблицы DiscTasks схемы тенанта),
|
||||
/// <see cref="ISettingsStore"/> (дефолты discEvalThreshold/discEvalSample) и <see cref="DiscoveryPlanGuard"/>
|
||||
/// (план-бюджет, Ruling 9). 404-семантика — null-результат (эндпоинт Task 19 отвечает «Задача не найдена»);
|
||||
/// 400-семантика — <see cref="DiscoveryValidationException"/> с текстом python (создание без имени/бюджет,
|
||||
/// start без ключевых слов). Прогресс поиска (SearchIdx/SearchDone/cчётчики) ведут методы
|
||||
/// <see cref="AdvanceSearchAsync"/> и <see cref="BumpCounterAsync"/> — их зовут кандидатный сервис и воркер (Task 18).
|
||||
/// </remarks>
|
||||
public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings)
|
||||
{
|
||||
/// <summary>
|
||||
/// 400 create: пустое название после Trim (create_task L242–243).
|
||||
/// </summary>
|
||||
public const string NameRequiredDetail = "Укажите название задачи";
|
||||
|
||||
/// <summary>
|
||||
/// 400 start: у задачи нет ключевых слов (start_task L330).
|
||||
/// </summary>
|
||||
public const string NoKeywordsDetail = "Нет ключевых слов для поиска — добавьте их в задачу";
|
||||
|
||||
/// <summary>
|
||||
/// Все задачи, старые первыми (list_tasks L189–191; воркер берёт самую старую running).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Задачи в порядке создания.</returns>
|
||||
public Task<IReadOnlyList<DiscoveryTaskDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
return store.ListTasksAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Одна задача по id (get_task L194–196).
|
||||
/// </summary>
|
||||
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Задача или null (404 «Задача не найдена» у эндпоинта).</returns>
|
||||
public Task<DiscoveryTaskDto?> GetAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
return store.GetTaskAsync(taskId, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт задачу поиска (create_task L234–282: имя, план 1..discJoinLimit, бюджет активных задач).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 1:1 с прототипом: name обязательное (после Trim); planJoins — дефолт 1 + <see cref="DiscoveryPlanGuard"/>;
|
||||
/// threshold/sampleSize — из настроек discEvalThreshold/discEvalSample (дефолты 40/10); границы значений
|
||||
/// клампятся как _validate_task_values L213–231. Задача создаётся draft, поиск не запущен; ключи ИИ-генерации
|
||||
/// при создании НЕ запрашиваются (endpoint generate-keywords — Task 19, прототип discovery_routes L189–211).
|
||||
/// </remarks>
|
||||
/// <param name="draft">Поля новой задачи (см. <see cref="DiscoveryTaskDraft"/>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Созданная задача (полный DTO).</returns>
|
||||
/// <exception cref="DiscoveryValidationException">Пустое имя / план вне 1..discJoinLimit / бюджет исчерпан.</exception>
|
||||
public async Task<DiscoveryTaskDto> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обновляет поля задачи (patch_task L285–311; рост plan_joins — с проверкой бюджета).
|
||||
/// </summary>
|
||||
/// <remarks>Поля патча нормализуются как _validate_task_values L213–231 (клампы/Trim); увеличение PlanJoins
|
||||
/// сверх текущего значения проходит <see cref="DiscoveryPlanGuard.AssertBudgetAsync"/> с исключением задачи.
|
||||
/// Пустой/полностью null патч — возврат текущей задачи без записи (python L289–290).</remarks>
|
||||
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
||||
/// <param name="patch">Изменяемые поля (null — не меняется).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Обновлённая задача либо null (404 «Задача не найдена»).</returns>
|
||||
/// <exception cref="DiscoveryValidationException">Новый план вне границ / бюджет исчерпан.</exception>
|
||||
public async Task<DiscoveryTaskDto?> 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())
|
||||
{
|
||||
// python L289–290: пустой патч — возврат текущей задачи без записи (updated_at не бампается).
|
||||
return current;
|
||||
}
|
||||
|
||||
await store.PatchTaskAsync(taskId, normalized, ct).ConfigureAwait(false);
|
||||
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет задачу вместе с кандидатами и логом (delete_task L314–318; чёрный список общий).
|
||||
/// </summary>
|
||||
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>True — задача удалена; false — строки нет (404 у эндпоинта).</returns>
|
||||
public async Task<bool> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запускает поиск (start_task L321–346): keywords непустые; status=running.
|
||||
/// </summary>
|
||||
/// <remarks>Повторный старт завершённой/упавшей (done/failed) сбрасывает прогресс поиска (свежий проход по
|
||||
/// ключам, L332–339); продолжение из paused сохраняет search_idx/счётчики (L342–344).</remarks>
|
||||
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Задача в running либо null (404).</returns>
|
||||
/// <exception cref="DiscoveryValidationException">Ключевых слов нет (текст start_task L330).</exception>
|
||||
public async Task<DiscoveryTaskDto?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ставит задачу на паузу (pause_task L349–356; прогресс поиска сохраняется).
|
||||
/// </summary>
|
||||
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Задача в paused либо null (404).</returns>
|
||||
public async Task<DiscoveryTaskDto?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Увеличивает счётчик прогресса задачи (bump_counter L359–368; n ≤ 0 — no-op, python max(0, n)).
|
||||
/// </summary>
|
||||
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
||||
/// <param name="field">Счётчик (found/evaluated/joined/rejected).</param>
|
||||
/// <param name="n">Приращение (по умолчанию 1).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>True — счётчик увеличен; false — задачи нет.</returns>
|
||||
public Task<bool> BumpCounterAsync(string taskId, DiscoveryCounterField field, int n, CancellationToken ct)
|
||||
{
|
||||
return store.BumpTaskCounterAsync(taskId, field, Math.Max(0, n), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Продвигает индекс поиска (advance_search L371–380): search_idx+1; конец ключей → search_done.
|
||||
/// </summary>
|
||||
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>True — задача обновлена; false — строки нет.</returns>
|
||||
public async Task<bool> 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);
|
||||
}
|
||||
|
||||
// Нормализует патч задачи (1:1 _validate_task_values L213–231: Trim/клампы/язык/ключи).
|
||||
// patch: Патч из запроса.
|
||||
// Возвращает: Патч с нормализованными значениями (null-поля сохраняются как «не менять»).
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// Очищает список ключей: Trim + без пустых (python L226–227).
|
||||
// keywords: Сырые ключи (null — пусто).
|
||||
// Возвращает: Список непустых ключей.
|
||||
private static IReadOnlyList<string> CleanKeywords(IReadOnlyList<string>? keywords)
|
||||
{
|
||||
return keywords?.Where(keyword => !string.IsNullOrWhiteSpace(keyword))
|
||||
.Select(keyword => keyword.Trim())
|
||||
.ToList() ?? new List<string>();
|
||||
}
|
||||
|
||||
// Кламп значения в границы [min, max] (эталон clamp python).
|
||||
private static int Clamp(int value, int min, int max)
|
||||
{
|
||||
return Math.Max(min, Math.Min(max, value));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user