using Deal.Contracts.Integrations.Models; using Deal.Modules.Discovery.Application.Abstractions; using Deal.Modules.Discovery.Application.Exceptions; using Deal.Modules.Discovery.Application.Models; namespace Deal.Modules.Discovery.Application.Services; /// /// Сервис кандидатов Discovery — add с исключениями, set_candidate, review-перевод, mark_joined/rejected, delete. /// public sealed class DiscoveryCandidatesService( IDiscoveryStore store, DiscoveryLogService log, DiscoveryBlacklistService blacklist) { /// /// 400 mark_rejected /// public const string RejectJoinedDetail = "Нельзя отклонить источник, в который уже вступили"; /// /// 400 set_candidate_status /// public const string TransitionNotAllowedFormat = "Статус {0} выставляется через mark_joined/mark_rejected"; private const string SkipMonitoredFormat = "пропущен {0}: источник уже мониторится (мы состоим)"; private const string SkipBlacklistedFormat = "пропущен {0}: источник в чёрном списке"; private const string SkipActiveFormat = "пропущен {0}: кандидат уже есть (статус {1})"; private const string ReviewLogFormat = "кандидат {0} переведён в review"; private const string JoinedLogFormat = "вступили в {0}"; private const string RejectedLogFormat = "отклонён {0}"; /// /// Кандидаты задачи, старые первыми. /// /// Id задачи (dt_...). /// Статус-фильтр (new|review|joined|rejected); null — все. /// Кандидаты задачи (marks/topics — списками). public Task> ListAsync( string taskId, string? status, CancellationToken ct) { return store.ListCandidatesAsync(taskId, status, ct); } /// /// Кандидат по dialog_id. /// /// Подписанный id источника. /// Кандидат или null (404 «Кандидат не найден»). public Task GetAsync(string dialogId, CancellationToken ct) { return store.GetCandidateAsync(dialogId, ct); } /// /// Добавляет найденный источник как кандидата задачи. /// /// Id задачи (dt_...). /// Подписанный id источника. /// Имя источника (пустое → DialogId). /// Username источника (пуст, если нет публичного). /// Тип источника: channel|group|forum (пусто → channel). /// Цвет источника (пусто → «#666»). /// Новый кандидат либо null — источник пропущен (в лог записан skip). public async Task AddAsync( string taskId, string dialogId, string name, string username, string kind, string hue, CancellationToken ct) { DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); if (task is null) { return null; } if (await store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false)) { await log.AddAsync(taskId, DiscoveryLogEvents.Skip, string.Format(SkipMonitoredFormat, dialogId), ct) .ConfigureAwait(false); return null; } if (await store.IsBlacklistedAsync(dialogId, ct).ConfigureAwait(false)) { await log.AddAsync(taskId, DiscoveryLogEvents.Skip, string.Format(SkipBlacklistedFormat, dialogId), ct) .ConfigureAwait(false); return null; } DiscoveryCandidateDto? existing = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); if (existing is not null && DiscoveryCandidateStatuses.IsActive(existing.Status)) { await log.AddAsync( taskId, DiscoveryLogEvents.Skip, string.Format(SkipActiveFormat, dialogId, existing.Status), ct).ConfigureAwait(false); return null; } if (existing is not null) { await store.DeleteCandidateAsync(dialogId, ct).ConfigureAwait(false); } var row = new DiscoveryCandidateRow { DialogId = dialogId, TaskId = taskId, Name = TrimOr(name, dialogId), Username = TrimOr(username, string.Empty), Kind = TrimOr(kind, DiscoveryCandidateKinds.Channel), Hue = TrimOr(hue, SourceDefaults.DefaultHue), }; await store.CreateCandidateAsync(row, ct).ConfigureAwait(false); await store.BumpTaskCounterAsync(taskId, DiscoveryCounterField.Found, 1, ct).ConfigureAwait(false); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); } /// /// Обновляет поля кандидата по результатам оценки. /// /// Id задачи. /// Подписанный id источника. /// Изменяемые поля (null — не менять). /// Обновлённый кандидат либо null — задачи/кандидата нет (или кандидат другой задачи). public async Task SetAsync( string taskId, string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct) { DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); if (task is null || current is null || current.TaskId != taskId) { return null; } DiscoveryCandidatePatch normalized = NormalizePatch(patch, current); bool hasChanges = normalized.Name is not null || normalized.Username is not null || normalized.Kind is not null || normalized.Hue is not null || normalized.Participants is not null || normalized.LangRu is not null || normalized.Marks is not null || normalized.Topics is not null || normalized.FitRatio is not null || normalized.AutoJoined is not null; if (!hasChanges) { return current; } await store.PatchCandidateAsync(dialogId, normalized, ct).ConfigureAwait(false); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); } /// /// Переводит кандидата в new/review. /// /// Подписанный id источника. /// Новый статус: new|review. /// Обновлённый кандидат либо null (кандидата нет). /// Статус не new/review. public async Task SetStatusAsync( string dialogId, string status, CancellationToken ct) { if (!DiscoveryCandidateStatuses.IsTransitionAllowed(status)) { throw new DiscoveryValidationException(string.Format(TransitionNotAllowedFormat, status)); } DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); if (current is null) { return null; } await store.SetCandidateStatusAsync(dialogId, status, ct).ConfigureAwait(false); if (status == DiscoveryCandidateStatuses.Review) { await log.AddAsync( current.TaskId, DiscoveryLogEvents.Review, string.Format(ReviewLogFormat, dialogId), ct).ConfigureAwait(false); } return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); } /// /// Вступили в источник /// /// Подписанный id источника. /// True — авто-вступление воркера; false — ручное. /// Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен. public async Task MarkJoinedAsync( string dialogId, bool auto, CancellationToken ct) { DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); if (current is null) { return null; } if (current.Status == DiscoveryCandidateStatuses.Joined) { return current; } await store.SetCandidateJoinedAsync(dialogId, auto, ct).ConfigureAwait(false); await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Joined, 1, ct).ConfigureAwait(false); string logEvent = auto ? DiscoveryLogEvents.JoinAuto : DiscoveryLogEvents.JoinManual; await log.AddAsync(current.TaskId, logEvent, string.Format(JoinedLogFormat, dialogId), ct).ConfigureAwait(false); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); } /// /// Отклоняет кандидата /// /// Подписанный id источника. /// Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject). /// Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен. /// Источник уже joined — отклонить нельзя. public async Task MarkRejectedAsync( string dialogId, string reason, CancellationToken ct) { DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); if (current is null) { return null; } if (current.Status == DiscoveryCandidateStatuses.Joined) { throw new DiscoveryValidationException(RejectJoinedDetail); } if (current.Status == DiscoveryCandidateStatuses.Rejected) { return current; } await store.SetCandidateRejectedAsync(dialogId, ct).ConfigureAwait(false); await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Rejected, 1, ct).ConfigureAwait(false); string text = string.IsNullOrEmpty(reason) ? string.Format(RejectedLogFormat, dialogId) : reason; await log.AddAsync(current.TaskId, DiscoveryLogEvents.Reject, text, ct).ConfigureAwait(false); await blacklist.AddAsync(dialogId, current.Name, reason ?? string.Empty, ct).ConfigureAwait(false); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); } /// /// Удаляет кандидата. /// /// Подписанный id источника. /// Завершается после удаления строки. public Task DeleteAsync(string dialogId, CancellationToken ct) { return store.DeleteCandidateAsync(dialogId, ct); } private static DiscoveryCandidatePatch NormalizePatch(DiscoveryCandidatePatch patch, DiscoveryCandidateDto current) { return new DiscoveryCandidatePatch { Name = NormalizeKeptOr(patch.Name, current.Name), Username = NormalizeKeptOr(patch.Username, current.Username), Kind = NormalizeKeptOr(patch.Kind, current.Kind), Hue = NormalizeKeptOr(patch.Hue, current.Hue), Participants = patch.Participants, LangRu = patch.LangRu, Marks = patch.Marks?.Select(mark => (string)mark).ToList(), Topics = patch.Topics?.ToList(), FitRatio = patch.FitRatio, AutoJoined = patch.AutoJoined, }; } private static string? NormalizeKeptOr(string? value, string current) { if (value is null) { return null; } string trimmed = value.Trim(); return trimmed.Length == 0 ? null : trimmed; } private static string TrimOr(string value, string fallback) { string trimmed = (value ?? string.Empty).Trim(); return trimmed.Length == 0 ? fallback : trimmed; } }