Files
Deal/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs
T
Rustam Khalimov 27c7831910
ci / build-test (push) Canceled after 0s
Deal — единая кодовая база
SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
2026-09-11 23:56:47 +03:00

310 lines
13 KiB
C#

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;
/// <summary>
/// Сервис кандидатов Discovery — add с исключениями, set_candidate, review-перевод, mark_joined/rejected, delete.
/// </summary>
public sealed class DiscoveryCandidatesService(
IDiscoveryStore store,
DiscoveryLogService log,
DiscoveryBlacklistService blacklist)
{
/// <summary>
/// 400 mark_rejected
/// </summary>
public const string RejectJoinedDetail = "Нельзя отклонить источник, в который уже вступили";
/// <summary>
/// 400 set_candidate_status
/// </summary>
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}";
/// <summary>
/// Кандидаты задачи, старые первыми.
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="status">Статус-фильтр (new|review|joined|rejected); null — все.</param>
/// <returns>Кандидаты задачи (marks/topics — списками).</returns>
public Task<IReadOnlyList<DiscoveryCandidateDto>> ListAsync(
string taskId,
string? status,
CancellationToken ct)
{
return store.ListCandidatesAsync(taskId, status, ct);
}
/// <summary>
/// Кандидат по dialog_id.
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <returns>Кандидат или null (404 «Кандидат не найден»).</returns>
public Task<DiscoveryCandidateDto?> GetAsync(string dialogId, CancellationToken ct)
{
return store.GetCandidateAsync(dialogId, ct);
}
/// <summary>
/// Добавляет найденный источник как кандидата задачи.
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="name">Имя источника (пустое → DialogId).</param>
/// <param name="username">Username источника (пуст, если нет публичного).</param>
/// <param name="kind">Тип источника: channel|group|forum (пусто → channel).</param>
/// <param name="hue">Цвет источника (пусто → «#666»).</param>
/// <returns>Новый кандидат либо null — источник пропущен (в лог записан skip).</returns>
public async Task<DiscoveryCandidateDto?> 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);
}
/// <summary>
/// Обновляет поля кандидата по результатам оценки.
/// </summary>
/// <param name="taskId">Id задачи.</param>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="patch">Изменяемые поля (null — не менять).</param>
/// <returns>Обновлённый кандидат либо null — задачи/кандидата нет (или кандидат другой задачи).</returns>
public async Task<DiscoveryCandidateDto?> 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);
}
/// <summary>
/// Переводит кандидата в new/review.
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="status">Новый статус: new|review.</param>
/// <returns>Обновлённый кандидат либо null (кандидата нет).</returns>
/// <exception cref="DiscoveryValidationException">Статус не new/review.</exception>
public async Task<DiscoveryCandidateDto?> 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);
}
/// <summary>
/// Вступили в источник
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="auto">True — авто-вступление воркера; false — ручное.</param>
/// <returns>Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен.</returns>
public async Task<DiscoveryCandidateDto?> 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);
}
/// <summary>
/// Отклоняет кандидата
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="reason">Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject).</param>
/// <returns>Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен.</returns>
/// <exception cref="DiscoveryValidationException">Источник уже joined — отклонить нельзя.</exception>
public async Task<DiscoveryCandidateDto?> 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);
}
/// <summary>
/// Удаляет кандидата.
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <returns>Завершается после удаления строки.</returns>
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;
}
}