Разбить модули Deal.Modules.* по назначению

Application проектов Discovery, Kanban, Pipeline, Settings, Tenants
разделён на Abstractions/Exceptions/Extensions/Models/Registrars/Services;
namespace приведён к путям, using потребителей мигрированы и
дедуплицированы (169 файлов), cref/FQN обновлены.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:18:14 +03:00
parent 31c434ed93
commit cd0b3b606b
356 changed files with 14436 additions and 12906 deletions
@@ -1,7 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Infrastructure.Integrations;
@@ -1,7 +1,9 @@
using System.Text.Json.Nodes;
using Deal.Grpc.Ai;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Infrastructure.Integrations;
@@ -1,7 +1,10 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.Logging;
@@ -1,7 +1,10 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.Logging;
@@ -1,6 +1,9 @@
using System.Globalization;
using System.Text.Json;
using Deal.Modules.Settings.Application;
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 Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations;
@@ -1,7 +1,10 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai;
using Deal.Modules.Pipeline.Application;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Pipeline.Application.Services;
using Deal.SharedKernel.Tenants;
using Grpc.Core;
using Microsoft.Extensions.Logging;
@@ -2,11 +2,20 @@ using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ml;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants;
using Grpc.Core;
using Microsoft.Extensions.Logging;
@@ -1,7 +1,9 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Pipeline.Application;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Pipeline.Application.Parse;
namespace Deal.Infrastructure.Integrations;
@@ -1,247 +1,252 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) —
// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство.
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
namespace Deal.Infrastructure.Integrations;
/// <summary>
/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика этапа 3 (Ruling 3, план Task 14).
/// </summary>
/// <remarks>
/// Реализует порт <see cref="IColumnSuggester"/> поверх порта <see cref="ICardStore"/> и чистого ядра
/// <see cref="SuggestHeuristics"/> (модуль Kanban): читает «Неразобранное» (ListInboxWithSourceAsync),
/// считает группы слов-тем и создаёт доски suggested=true (RulesJson {mode:"any", keywords:[…]},
/// note-обоснование, цвет/позицию даёт ContainersService) и раскладывает карточки (is_new=TRUE,
/// prev_col='inbox', matchHits по правилам доски — Ruling 2). Причины отказов — детерминированные
/// строки прототипа/Ruling 3: «мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже
/// есть или нечего сгруппировать»; кулдаун повторов — KV-ключ <see cref="SettingsKeys.LastSuggestAt"/>
/// (прототип COOLDOWN_S L51 + «недавно предлагали — подождите» L95). Журнал CardMoves/ML-сигналы при
/// раскладке НЕ пишутся (suggest.py _assign_ids L220–239 — это не действие пользователя, а предложение).
/// Suggest-keywords читает карточки вне trash/archive (suggest_domain_keywords L172–178).
/// </remarks>
/// <param name="store">Порт хранилища (карточки «Неразобранного», переносы в колонки-доски).</param>
/// <param name="settings">KV-хранилище настроек тенанта (кулдаун lastSuggestAt, как KEY suggest.py L52).</param>
/// <param name="containersService">Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами.</param>
public sealed class LocalColumnSuggester(
ICardStore store,
ISettingsStore settings,
ContainersService containersService) : IColumnSuggester
{
// ── Кулдаун повторов (suggest.py COOLDOWN_S L51; KEY lastSuggestAt L52) ──
// Как часто можно переспрашивать ИИ-предложения: 20 минут (COOLDOWN_S = 20 * 60, L51).
private const long CooldownSeconds = 20 * 60;
// ── Детерминированные причины (Ruling 3; строки прототипа suggest.py) ──
// Кулдаун: повторный вызов слишком рано (suggest.py L95 «недавно предлагали — подождите»).
private const string CooldownReason = "недавно предлагали — подождите";
// Мало карточек в «Неразобранном»: {0} — порог MIN_INBOX (suggest.py L102).
private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})";
// Групп не вышло: темы похожи на существующие доски или карточкам нечего разделить (L159).
private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать";
// Мало карточек для ключей: нужно хотя бы 3 (suggest_domain_keywords L178).
private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)";
// Повторяющихся слов-маркеров не нашлось (suggest_domain_keywords L187, текст прототипа).
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
// Режим правил колонки-предложения: «любое из условий» (suggest.py _rules_for L68 mode: any).
private const string RulesModeAny = "any";
/// <inheritdoc />
public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct)
{
if (await WithinCooldownAsync(ct))
{
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: CooldownReason, Cooldown: true);
}
IReadOnlyList<CardDto> inbox = await store.ListInboxWithSourceAsync(ct);
if (inbox.Count < SuggestHeuristics.MinInbox)
{
return new SuggestColumnsResultDto(
Ok: false,
Created: 0,
Reason: string.Format(TooFewCardsReasonFormat, SuggestHeuristics.MinInbox),
Cooldown: false);
}
// Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138–139).
IReadOnlyList<ContainerDto> containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct);
IReadOnlyList<string> existingNames = containers
.Where(container => !container.Suggested)
.Select(container => container.Name)
.ToList();
IReadOnlyList<SuggestedColumnPlan> plans = SuggestHeuristics.PlanColumns(inbox, existingNames);
if (plans.Count == 0)
{
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
}
int created = await StoreSuggestedColumnsAsync(inbox, plans, ct);
if (created == 0)
{
// Все колонки откатаны: карточки групп разобраны между чтением и раскладкой (suggest.py L153–156).
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
}
await WriteLastSuggestAtAsync(ct);
return new SuggestColumnsResultDto(Ok: true, Created: created, Reason: null, Cooldown: false);
}
/// <inheritdoc />
public async Task<SuggestKeywordsResultDto> SuggestKeywordsAsync(CancellationToken ct)
{
// Выборка ключей — как suggest_domain_keywords L172–176: карточки вне trash/archive с текстом,
// свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC).
IReadOnlyList<CardDto> cards = await store.ListCardsAsync(new CardsQuery(null), ct);
List<string> texts = cards
.Where(card => card.Col != CardIds.Trash
&& card.Col != CardIds.Archive
&& card.SourceMsg.Trim().Length > 0)
.Take(SuggestHeuristics.KeywordsSampleLimit)
.Select(card => card.SourceMsg.Trim())
.ToList();
if (texts.Count < SuggestHeuristics.MinKeywordsSample)
{
return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsTooFewReason);
}
IReadOnlyList<string> keywords = SuggestHeuristics.SuggestDomainKeywords(texts);
if (keywords.Count == 0)
{
return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsEmptyReason);
}
return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null);
}
// Создаёт доски-предложения по планам и раскладывает карточки (suggest.py L129–156).
// inbox: Снимок «Неразобранного» (карточки планов берутся из него).
// plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4).
// ct: Токен отмены.
// Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек).
// Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием.
// Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и
// раскладкой (пользователь/тик), пропускаются — 1:1 со страховкой _assign_ids L231–233. Если в
// колонку не легло ни одной карточки, пустая доска-предложение откатывается (_rollback_suggested
// L242–248). matchHits считаются по правилам созданной доски (Ruling 2); журнал/ML не пишутся.
private async Task<int> StoreSuggestedColumnsAsync(
IReadOnlyList<CardDto> inbox,
IReadOnlyList<SuggestedColumnPlan> plans,
CancellationToken ct)
{
// Свежий снимок inbox — страховка «карточку уже разобрали» (suggest.py _assign_ids L231–233).
HashSet<string> inboxIds = (await store.ListInboxWithSourceAsync(ct))
.Select(card => card.Id)
.ToHashSet(StringComparer.Ordinal);
Dictionary<string, string> textByCardId = inbox
.ToDictionary(card => card.Id, card => card.SourceMsg, StringComparer.Ordinal);
int created = 0;
foreach (SuggestedColumnPlan plan in plans)
{
var rules = new ContainerRulesDto(
Mode: RulesModeAny,
Direction: Array.Empty<string>(),
Keywords: [plan.Word],
Stack: Array.Empty<string>(),
Grade: Array.Empty<string>(),
Exclude: Array.Empty<string>(),
Budget: null);
ContainerDto container = await containersService.CreateAsync(new ContainerCreateDto(
Name: plan.Name,
Description: string.Empty,
Color: null,
Space: ContainerSpaces.Dashboard,
Kind: ContainerKinds.Board,
Suggested: true,
Rules: rules,
Note: plan.Note), ct);
int placed = 0;
foreach (string cardId in plan.CardIds)
{
if (!inboxIds.Contains(cardId))
{
continue; // карточка уже разобрана другим предложением/пользователем (L231–233)
}
IReadOnlyList<MatchHitDto> hits = KanbanColumnRules.ComputeHits(rules, textByCardId[cardId]);
await store.UpdateColumnAsync(new CardColumnUpdateDto(
CardId: cardId,
Col: container.Id,
IsNew: true,
PrevCol: CardIds.Inbox,
ArchivedAt: null,
MatchHits: hits), ct);
placed++;
}
if (placed == 0)
{
// Ничего не легло — пустое предложение не нужно (suggest.py L152–156).
await containersService.DeleteAsync(container.Id, ct);
continue;
}
created++;
}
return created;
}
// Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут.
// Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение
// пишется только после успеха, L160–161; битый KV — дефолт «никогда»).
// ct: Токен отмены.
// Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}).
private async Task<bool> WithinCooldownAsync(CancellationToken ct)
{
SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct);
if (row is null)
{
return false;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
if (document.RootElement.ValueKind != JsonValueKind.Number)
{
return false;
}
long lastSuggestAt = document.RootElement.GetInt64();
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lastSuggestAt < CooldownSeconds;
}
catch (JsonException)
{
return false; // повреждённое значение не должно блокировать предложения
}
}
// Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())).
// ct: Токен отмены.
private Task WriteLastSuggestAtAsync(CancellationToken ct) =>
settings.SetAsync(
SettingsKeys.LastSuggestAt,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
ct);
}
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.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;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) —
// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство.
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
namespace Deal.Infrastructure.Integrations;
/// <summary>
/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика этапа 3 (Ruling 3, план Task 14).
/// </summary>
/// <remarks>
/// Реализует порт <see cref="IColumnSuggester"/> поверх порта <see cref="ICardStore"/> и чистого ядра
/// <see cref="SuggestHeuristics"/> (модуль Kanban): читает «Неразобранное» (ListInboxWithSourceAsync),
/// считает группы слов-тем и создаёт доски suggested=true (RulesJson {mode:"any", keywords:[…]},
/// note-обоснование, цвет/позицию даёт ContainersService) и раскладывает карточки (is_new=TRUE,
/// prev_col='inbox', matchHits по правилам доски — Ruling 2). Причины отказов — детерминированные
/// строки прототипа/Ruling 3: «мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже
/// есть или нечего сгруппировать»; кулдаун повторов — KV-ключ <see cref="SettingsKeys.LastSuggestAt"/>
/// (прототип COOLDOWN_S L51 + «недавно предлагали — подождите» L95). Журнал CardMoves/ML-сигналы при
/// раскладке НЕ пишутся (suggest.py _assign_ids L220–239 — это не действие пользователя, а предложение).
/// Suggest-keywords читает карточки вне trash/archive (suggest_domain_keywords L172–178).
/// </remarks>
/// <param name="store">Порт хранилища (карточки «Неразобранного», переносы в колонки-доски).</param>
/// <param name="settings">KV-хранилище настроек тенанта (кулдаун lastSuggestAt, как KEY suggest.py L52).</param>
/// <param name="containersService">Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами.</param>
public sealed class LocalColumnSuggester(
ICardStore store,
ISettingsStore settings,
ContainersService containersService) : IColumnSuggester
{
// ── Кулдаун повторов (suggest.py COOLDOWN_S L51; KEY lastSuggestAt L52) ──
// Как часто можно переспрашивать ИИ-предложения: 20 минут (COOLDOWN_S = 20 * 60, L51).
private const long CooldownSeconds = 20 * 60;
// ── Детерминированные причины (Ruling 3; строки прототипа suggest.py) ──
// Кулдаун: повторный вызов слишком рано (suggest.py L95 «недавно предлагали — подождите»).
private const string CooldownReason = "недавно предлагали — подождите";
// Мало карточек в «Неразобранном»: {0} — порог MIN_INBOX (suggest.py L102).
private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})";
// Групп не вышло: темы похожи на существующие доски или карточкам нечего разделить (L159).
private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать";
// Мало карточек для ключей: нужно хотя бы 3 (suggest_domain_keywords L178).
private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)";
// Повторяющихся слов-маркеров не нашлось (suggest_domain_keywords L187, текст прототипа).
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
// Режим правил колонки-предложения: «любое из условий» (suggest.py _rules_for L68 mode: any).
private const string RulesModeAny = "any";
/// <inheritdoc />
public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct)
{
if (await WithinCooldownAsync(ct))
{
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: CooldownReason, Cooldown: true);
}
IReadOnlyList<CardDto> inbox = await store.ListInboxWithSourceAsync(ct);
if (inbox.Count < SuggestHeuristics.MinInbox)
{
return new SuggestColumnsResultDto(
Ok: false,
Created: 0,
Reason: string.Format(TooFewCardsReasonFormat, SuggestHeuristics.MinInbox),
Cooldown: false);
}
// Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138–139).
IReadOnlyList<ContainerDto> containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct);
IReadOnlyList<string> existingNames = containers
.Where(container => !container.Suggested)
.Select(container => container.Name)
.ToList();
IReadOnlyList<SuggestedColumnPlan> plans = SuggestHeuristics.PlanColumns(inbox, existingNames);
if (plans.Count == 0)
{
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
}
int created = await StoreSuggestedColumnsAsync(inbox, plans, ct);
if (created == 0)
{
// Все колонки откатаны: карточки групп разобраны между чтением и раскладкой (suggest.py L153–156).
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
}
await WriteLastSuggestAtAsync(ct);
return new SuggestColumnsResultDto(Ok: true, Created: created, Reason: null, Cooldown: false);
}
/// <inheritdoc />
public async Task<SuggestKeywordsResultDto> SuggestKeywordsAsync(CancellationToken ct)
{
// Выборка ключей — как suggest_domain_keywords L172–176: карточки вне trash/archive с текстом,
// свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC).
IReadOnlyList<CardDto> cards = await store.ListCardsAsync(new CardsQuery(null), ct);
List<string> texts = cards
.Where(card => card.Col != CardIds.Trash
&& card.Col != CardIds.Archive
&& card.SourceMsg.Trim().Length > 0)
.Take(SuggestHeuristics.KeywordsSampleLimit)
.Select(card => card.SourceMsg.Trim())
.ToList();
if (texts.Count < SuggestHeuristics.MinKeywordsSample)
{
return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsTooFewReason);
}
IReadOnlyList<string> keywords = SuggestHeuristics.SuggestDomainKeywords(texts);
if (keywords.Count == 0)
{
return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsEmptyReason);
}
return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null);
}
// Создаёт доски-предложения по планам и раскладывает карточки (suggest.py L129–156).
// inbox: Снимок «Неразобранного» (карточки планов берутся из него).
// plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4).
// ct: Токен отмены.
// Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек).
// Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием.
// Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и
// раскладкой (пользователь/тик), пропускаются — 1:1 со страховкой _assign_ids L231–233. Если в
// колонку не легло ни одной карточки, пустая доска-предложение откатывается (_rollback_suggested
// L242–248). matchHits считаются по правилам созданной доски (Ruling 2); журнал/ML не пишутся.
private async Task<int> StoreSuggestedColumnsAsync(
IReadOnlyList<CardDto> inbox,
IReadOnlyList<SuggestedColumnPlan> plans,
CancellationToken ct)
{
// Свежий снимок inbox — страховка «карточку уже разобрали» (suggest.py _assign_ids L231–233).
HashSet<string> inboxIds = (await store.ListInboxWithSourceAsync(ct))
.Select(card => card.Id)
.ToHashSet(StringComparer.Ordinal);
Dictionary<string, string> textByCardId = inbox
.ToDictionary(card => card.Id, card => card.SourceMsg, StringComparer.Ordinal);
int created = 0;
foreach (SuggestedColumnPlan plan in plans)
{
var rules = new ContainerRulesDto(
Mode: RulesModeAny,
Direction: Array.Empty<string>(),
Keywords: [plan.Word],
Stack: Array.Empty<string>(),
Grade: Array.Empty<string>(),
Exclude: Array.Empty<string>(),
Budget: null);
ContainerDto container = await containersService.CreateAsync(new ContainerCreateDto(
Name: plan.Name,
Description: string.Empty,
Color: null,
Space: ContainerSpaces.Dashboard,
Kind: ContainerKinds.Board,
Suggested: true,
Rules: rules,
Note: plan.Note), ct);
int placed = 0;
foreach (string cardId in plan.CardIds)
{
if (!inboxIds.Contains(cardId))
{
continue; // карточка уже разобрана другим предложением/пользователем (L231–233)
}
IReadOnlyList<MatchHitDto> hits = KanbanColumnRules.ComputeHits(rules, textByCardId[cardId]);
await store.UpdateColumnAsync(new CardColumnUpdateDto(
CardId: cardId,
Col: container.Id,
IsNew: true,
PrevCol: CardIds.Inbox,
ArchivedAt: null,
MatchHits: hits), ct);
placed++;
}
if (placed == 0)
{
// Ничего не легло — пустое предложение не нужно (suggest.py L152–156).
await containersService.DeleteAsync(container.Id, ct);
continue;
}
created++;
}
return created;
}
// Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут.
// Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение
// пишется только после успеха, L160–161; битый KV — дефолт «никогда»).
// ct: Токен отмены.
// Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}).
private async Task<bool> WithinCooldownAsync(CancellationToken ct)
{
SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct);
if (row is null)
{
return false;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
if (document.RootElement.ValueKind != JsonValueKind.Number)
{
return false;
}
long lastSuggestAt = document.RootElement.GetInt64();
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lastSuggestAt < CooldownSeconds;
}
catch (JsonException)
{
return false; // повреждённое значение не должно блокировать предложения
}
}
// Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())).
// ct: Токен отмены.
private Task WriteLastSuggestAtAsync(CancellationToken ct) =>
settings.SetAsync(
SettingsKeys.LastSuggestAt,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
ct);
}
@@ -1,9 +1,15 @@
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Settings.Application;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.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;
namespace Deal.Infrastructure.Integrations;
@@ -1,5 +1,9 @@
using System.Security.Cryptography;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
namespace Deal.Infrastructure.Integrations;
@@ -1,10 +1,15 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Deal.Grpc.Ai;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Observability;
using Deal.SharedKernel.Tenants;
@@ -6,7 +6,7 @@ namespace Deal.Infrastructure.Persistence.Entities;
/// <remarks>
/// Единое KV-хранилище всего SaaS-контура (ТЗ §4.1/§8.1): значения задаёт оператор, видят все
/// тенанты. Секреты хранятся зашифрованными (префикс <c>enc:</c>), формат значения определяет ключ
/// (<see cref="Deal.Modules.Settings.Application.GlobalSettingsKeys"/>).
/// (<see cref="Deal.Modules.Settings.Application.Models.GlobalSettingsKeys"/>).
/// </remarks>
public sealed class GlobalSettingEntity
{
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,65 +1,69 @@
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application;
using Deal.Modules.Discovery.Application.Models;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// Чёрный список Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего
/// файла, поведение не менялось): upsert/удаление/чтение/список DiscBlacklist (ON CONFLICT DO UPDATE).
/// </summary>
public sealed partial class DiscoveryStore
{
/// <inheritdoc />
public async Task UpsertBlacklistAsync(string dialogId, string name, string reason, CancellationToken ct)
{
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
.FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
if (row is null)
{
_dbContext.DiscBlacklist.Add(new DiscBlacklistEntity
{
DialogId = dialogId,
Name = name,
Reason = reason,
CreatedAt = DateTimeOffset.UtcNow,
});
}
else
{
// add_blacklist L572–575 (ON CONFLICT DO UPDATE): name/reason обновляются, CreatedAt сохраняется.
row.Name = name;
row.Reason = reason;
}
await _dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task RemoveBlacklistAsync(string dialogId, CancellationToken ct)
{
await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct);
}
/// <inheritdoc />
public async Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct)
{
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
.AsNoTracking()
.FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
return row is null ? null : ToBlacklistDto(row);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct)
{
List<DiscBlacklistEntity> rows = await _dbContext.DiscBlacklist
.AsNoTracking()
.OrderByDescending(entry => entry.CreatedAt)
.ToListAsync(ct);
return rows.Select(ToBlacklistDto).ToList();
}
}
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
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 Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// Чёрный список Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего
/// файла, поведение не менялось): upsert/удаление/чтение/список DiscBlacklist (ON CONFLICT DO UPDATE).
/// </summary>
public sealed partial class DiscoveryStore
{
/// <inheritdoc />
public async Task UpsertBlacklistAsync(string dialogId, string name, string reason, CancellationToken ct)
{
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
.FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
if (row is null)
{
_dbContext.DiscBlacklist.Add(new DiscBlacklistEntity
{
DialogId = dialogId,
Name = name,
Reason = reason,
CreatedAt = DateTimeOffset.UtcNow,
});
}
else
{
// add_blacklist L572–575 (ON CONFLICT DO UPDATE): name/reason обновляются, CreatedAt сохраняется.
row.Name = name;
row.Reason = reason;
}
await _dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task RemoveBlacklistAsync(string dialogId, CancellationToken ct)
{
await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct);
}
/// <inheritdoc />
public async Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct)
{
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
.AsNoTracking()
.FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
return row is null ? null : ToBlacklistDto(row);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct)
{
List<DiscBlacklistEntity> rows = await _dbContext.DiscBlacklist
.AsNoTracking()
.OrderByDescending(entry => entry.CreatedAt)
.ToListAsync(ct);
return rows.Select(ToBlacklistDto).ToList();
}
}
@@ -1,158 +1,162 @@
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application;
using Deal.Modules.Discovery.Application.Models;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// Кандидаты Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
/// поведение не менялось): список/чтение кандидатов (DiscCandidates), мониторинг диалога и чёрный список,
/// создание/патч/статусы joined/rejected и счётчик сбоев вступлений.
/// </summary>
public sealed partial class DiscoveryStore
{
/// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(string taskId, string? status, CancellationToken ct)
{
IQueryable<DiscCandidateEntity> query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId);
if (status is not null)
{
query = query.Where(candidate => candidate.Status == status);
}
List<DiscCandidateEntity> rows = await query.OrderBy(candidate => candidate.CreatedAt).ToListAsync(ct);
return rows.Select(ToCandidateDto).ToList();
}
/// <inheritdoc />
public async Task<DiscoveryCandidateDto?> GetCandidateAsync(string dialogId, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.AsNoTracking()
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
return row is null ? null : ToCandidateDto(row);
}
/// <inheritdoc />
public async Task<bool> IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
{
return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct);
}
/// <inheritdoc />
public Task<bool> IsBlacklistedAsync(string dialogId, CancellationToken ct)
{
return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct);
}
/// <inheritdoc />
public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
_dbContext.DiscCandidates.Add(new DiscCandidateEntity
{
DialogId = row.DialogId,
TaskId = row.TaskId,
Name = row.Name,
Username = row.Username,
Kind = row.Kind,
Hue = row.Hue,
Status = "new",
CreatedAt = now,
UpdatedAt = now,
});
await _dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task DeleteCandidateAsync(string dialogId, CancellationToken ct)
{
await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct);
}
/// <inheritdoc />
public async Task<bool> PatchCandidateAsync(string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null)
{
return false;
}
ApplyCandidatePatch(row, patch);
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> SetCandidateStatusAsync(string dialogId, string status, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null)
{
return false;
}
row.Status = status;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> SetCandidateJoinedAsync(string dialogId, bool autoJoined, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null)
{
return false;
}
// mark_joined L531–534: status=joined + auto_joined.
row.Status = "joined";
row.AutoJoined = autoJoined;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<int?> IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null || row.Status != "review")
{
// воркер L404–416: счётчик и удаление трогаем только у живой записи в статусе review.
return null;
}
row.JoinFailures += 1;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return row.JoinFailures;
}
/// <inheritdoc />
public async Task<bool> SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null)
{
return false;
}
row.Status = "rejected";
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
}
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
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 Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// Кандидаты Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
/// поведение не менялось): список/чтение кандидатов (DiscCandidates), мониторинг диалога и чёрный список,
/// создание/патч/статусы joined/rejected и счётчик сбоев вступлений.
/// </summary>
public sealed partial class DiscoveryStore
{
/// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(string taskId, string? status, CancellationToken ct)
{
IQueryable<DiscCandidateEntity> query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId);
if (status is not null)
{
query = query.Where(candidate => candidate.Status == status);
}
List<DiscCandidateEntity> rows = await query.OrderBy(candidate => candidate.CreatedAt).ToListAsync(ct);
return rows.Select(ToCandidateDto).ToList();
}
/// <inheritdoc />
public async Task<DiscoveryCandidateDto?> GetCandidateAsync(string dialogId, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.AsNoTracking()
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
return row is null ? null : ToCandidateDto(row);
}
/// <inheritdoc />
public async Task<bool> IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
{
return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct);
}
/// <inheritdoc />
public Task<bool> IsBlacklistedAsync(string dialogId, CancellationToken ct)
{
return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct);
}
/// <inheritdoc />
public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
_dbContext.DiscCandidates.Add(new DiscCandidateEntity
{
DialogId = row.DialogId,
TaskId = row.TaskId,
Name = row.Name,
Username = row.Username,
Kind = row.Kind,
Hue = row.Hue,
Status = "new",
CreatedAt = now,
UpdatedAt = now,
});
await _dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task DeleteCandidateAsync(string dialogId, CancellationToken ct)
{
await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct);
}
/// <inheritdoc />
public async Task<bool> PatchCandidateAsync(string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null)
{
return false;
}
ApplyCandidatePatch(row, patch);
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> SetCandidateStatusAsync(string dialogId, string status, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null)
{
return false;
}
row.Status = status;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> SetCandidateJoinedAsync(string dialogId, bool autoJoined, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null)
{
return false;
}
// mark_joined L531–534: status=joined + auto_joined.
row.Status = "joined";
row.AutoJoined = autoJoined;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<int?> IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null || row.Status != "review")
{
// воркер L404–416: счётчик и удаление трогаем только у живой записи в статусе review.
return null;
}
row.JoinFailures += 1;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return row.JoinFailures;
}
/// <inheritdoc />
public async Task<bool> SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null)
{
return false;
}
row.Status = "rejected";
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
}
@@ -1,47 +1,51 @@
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application;
using Deal.Modules.Discovery.Application.Models;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// Лог Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
/// поведение не менялось): запись события, счётчик событий с начала суток (квота вступлений) и лог задачи.
/// </summary>
public sealed partial class DiscoveryStore
{
/// <inheritdoc />
public async Task AddLogAsync(string logId, string taskId, string logEvent, string text, CancellationToken ct)
{
_dbContext.DiscLog.Add(new DiscLogEntity
{
Id = logId,
TaskId = taskId,
Event = logEvent,
Text = text,
CreatedAt = DateTimeOffset.UtcNow,
});
await _dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task<int> CountLogEventAsync(string logEvent, DateTimeOffset sinceUtc, CancellationToken ct)
{
// ban_guard.joins_today_auto L29–35: число событий лога по типу с начала UTC-суток (счётчик квоты).
return await _dbContext.DiscLog.CountAsync(row => row.Event == logEvent && row.CreatedAt >= sinceUtc, ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(string taskId, int limit, CancellationToken ct)
{
List<DiscLogEntity> rows = await _dbContext.DiscLog
.AsNoTracking()
.Where(log => log.TaskId == taskId)
.OrderByDescending(log => log.CreatedAt)
.Take(limit)
.ToListAsync(ct);
return rows.Select(ToLogDto).ToList();
}
}
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
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 Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// Лог Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
/// поведение не менялось): запись события, счётчик событий с начала суток (квота вступлений) и лог задачи.
/// </summary>
public sealed partial class DiscoveryStore
{
/// <inheritdoc />
public async Task AddLogAsync(string logId, string taskId, string logEvent, string text, CancellationToken ct)
{
_dbContext.DiscLog.Add(new DiscLogEntity
{
Id = logId,
TaskId = taskId,
Event = logEvent,
Text = text,
CreatedAt = DateTimeOffset.UtcNow,
});
await _dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task<int> CountLogEventAsync(string logEvent, DateTimeOffset sinceUtc, CancellationToken ct)
{
// ban_guard.joins_today_auto L29–35: число событий лога по типу с начала UTC-суток (счётчик квоты).
return await _dbContext.DiscLog.CountAsync(row => row.Event == logEvent && row.CreatedAt >= sinceUtc, ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(string taskId, int limit, CancellationToken ct)
{
List<DiscLogEntity> rows = await _dbContext.DiscLog
.AsNoTracking()
.Where(log => log.TaskId == taskId)
.OrderByDescending(log => log.CreatedAt)
.Take(limit)
.ToListAsync(ct);
return rows.Select(ToLogDto).ToList();
}
}
@@ -1,217 +1,221 @@
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application;
using Deal.Modules.Discovery.Application.Models;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// Задачи Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
/// поведение не менялось): CRUD задач (DiscTasks), состояние running/paused/done, атомарные инкременты
/// счётчиков и сдвиг поиска, сумма плана (discovery.py db.py; Ruling 9, Task 17).
/// </summary>
public sealed partial class DiscoveryStore
{
/// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct)
{
List<DiscTaskEntity> rows = await _dbContext.DiscTasks
.AsNoTracking()
.OrderBy(task => task.CreatedAt)
.ToListAsync(ct);
return rows.Select(ToTaskDto).ToList();
}
/// <inheritdoc />
public async Task<DiscoveryTaskDto?> GetTaskAsync(string taskId, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.AsNoTracking()
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
return row is null ? null : ToTaskDto(row);
}
/// <inheritdoc />
public async Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
_dbContext.DiscTasks.Add(new DiscTaskEntity
{
Id = row.Id,
Name = row.Name,
Description = row.Description,
KeywordsJson = ToJson(row.Keywords),
MinSubscribers = row.MinSubscribers,
Lang = row.Lang,
Threshold = row.Threshold,
SampleSize = row.SampleSize,
PlanJoins = row.PlanJoins,
AutoJoin = row.AutoJoin,
Status = "draft",
CreatedAt = now,
UpdatedAt = now,
});
await _dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task<bool> PatchTaskAsync(string taskId, DiscoveryTaskPatch patch, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
{
return false;
}
ApplyTaskPatch(row, patch);
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> DeleteTaskAsync(string taskId, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
{
return false;
}
// delete_task L314–318: задача удаляется вместе с кандидатами и логом; чёрный список общий — не трогаем.
await _dbContext.DiscCandidates.Where(candidate => candidate.TaskId == taskId).ExecuteDeleteAsync(ct);
await _dbContext.DiscLog.Where(log => log.TaskId == taskId).ExecuteDeleteAsync(ct);
_dbContext.DiscTasks.Remove(row);
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> SetTaskRunningAsync(string taskId, bool resetProgress, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
{
return false;
}
row.Status = "running";
if (resetProgress)
{
// start_task L333–339: повторный прогон завершённой/упавшей — свежий проход по ключам.
row.SearchIdx = 0;
row.SearchDone = false;
row.Found = 0;
row.Evaluated = 0;
row.Joined = 0;
row.Rejected = 0;
}
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> SetTaskPausedAsync(string taskId, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
{
return false;
}
row.Status = "paused";
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> SetTaskDoneAsync(string taskId, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
{
return false;
}
// воркер _finish_done L126–136: план вступлений выполнен — status=done, бюджет планов освобождается.
row.Status = "done";
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> BumpTaskCounterAsync(string taskId, DiscoveryCounterField field, int n, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null || n <= 0)
{
return row is not null;
}
// bump_counter L359–368: приращение счётчика + bump UpdatedAt (python читает и пишет абсолютное значение).
switch (field)
{
case DiscoveryCounterField.Found:
row.Found += n;
break;
case DiscoveryCounterField.Evaluated:
row.Evaluated += n;
break;
case DiscoveryCounterField.Joined:
row.Joined += n;
break;
case DiscoveryCounterField.Rejected:
row.Rejected += n;
break;
default:
throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестный счётчик задачи");
}
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> AdvanceSearchAsync(string taskId, int nextIndex, bool searchDone, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
{
return false;
}
// advance_search L371–380: новый индекс и флаг завершения прохода (значения считает сервис).
row.SearchIdx = nextIndex;
row.SearchDone = searchDone;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<int> SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
{
IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks
.Where(task => task.Status != "done" && task.Status != "failed");
if (excludeTaskId is not null)
{
query = query.Where(task => task.Id != excludeTaskId);
}
return await query.SumAsync(task => task.PlanJoins, ct);
}
}
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
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 Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// Задачи Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
/// поведение не менялось): CRUD задач (DiscTasks), состояние running/paused/done, атомарные инкременты
/// счётчиков и сдвиг поиска, сумма плана (discovery.py db.py; Ruling 9, Task 17).
/// </summary>
public sealed partial class DiscoveryStore
{
/// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct)
{
List<DiscTaskEntity> rows = await _dbContext.DiscTasks
.AsNoTracking()
.OrderBy(task => task.CreatedAt)
.ToListAsync(ct);
return rows.Select(ToTaskDto).ToList();
}
/// <inheritdoc />
public async Task<DiscoveryTaskDto?> GetTaskAsync(string taskId, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.AsNoTracking()
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
return row is null ? null : ToTaskDto(row);
}
/// <inheritdoc />
public async Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
_dbContext.DiscTasks.Add(new DiscTaskEntity
{
Id = row.Id,
Name = row.Name,
Description = row.Description,
KeywordsJson = ToJson(row.Keywords),
MinSubscribers = row.MinSubscribers,
Lang = row.Lang,
Threshold = row.Threshold,
SampleSize = row.SampleSize,
PlanJoins = row.PlanJoins,
AutoJoin = row.AutoJoin,
Status = "draft",
CreatedAt = now,
UpdatedAt = now,
});
await _dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task<bool> PatchTaskAsync(string taskId, DiscoveryTaskPatch patch, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
{
return false;
}
ApplyTaskPatch(row, patch);
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> DeleteTaskAsync(string taskId, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
{
return false;
}
// delete_task L314–318: задача удаляется вместе с кандидатами и логом; чёрный список общий — не трогаем.
await _dbContext.DiscCandidates.Where(candidate => candidate.TaskId == taskId).ExecuteDeleteAsync(ct);
await _dbContext.DiscLog.Where(log => log.TaskId == taskId).ExecuteDeleteAsync(ct);
_dbContext.DiscTasks.Remove(row);
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> SetTaskRunningAsync(string taskId, bool resetProgress, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
{
return false;
}
row.Status = "running";
if (resetProgress)
{
// start_task L333–339: повторный прогон завершённой/упавшей — свежий проход по ключам.
row.SearchIdx = 0;
row.SearchDone = false;
row.Found = 0;
row.Evaluated = 0;
row.Joined = 0;
row.Rejected = 0;
}
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> SetTaskPausedAsync(string taskId, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
{
return false;
}
row.Status = "paused";
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> SetTaskDoneAsync(string taskId, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
{
return false;
}
// воркер _finish_done L126–136: план вступлений выполнен — status=done, бюджет планов освобождается.
row.Status = "done";
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> BumpTaskCounterAsync(string taskId, DiscoveryCounterField field, int n, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null || n <= 0)
{
return row is not null;
}
// bump_counter L359–368: приращение счётчика + bump UpdatedAt (python читает и пишет абсолютное значение).
switch (field)
{
case DiscoveryCounterField.Found:
row.Found += n;
break;
case DiscoveryCounterField.Evaluated:
row.Evaluated += n;
break;
case DiscoveryCounterField.Joined:
row.Joined += n;
break;
case DiscoveryCounterField.Rejected:
row.Rejected += n;
break;
default:
throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестный счётчик задачи");
}
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<bool> AdvanceSearchAsync(string taskId, int nextIndex, bool searchDone, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
{
return false;
}
// advance_search L371–380: новый индекс и флаг завершения прохода (значения считает сервис).
row.SearchIdx = nextIndex;
row.SearchDone = searchDone;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<int> SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
{
IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks
.Where(task => task.Status != "done" && task.Status != "failed");
if (excludeTaskId is not null)
{
query = query.Where(task => task.Id != excludeTaskId);
}
return await query.SumAsync(task => task.PlanJoins, ct);
}
}
@@ -1,219 +1,223 @@
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application;
using Deal.Modules.Discovery.Application.Models;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17).
/// </summary>
/// <remarks>
/// Реализация порта <see cref="IDiscoveryStore"/> на <see cref="TenantDbContext"/> (эталон TelegramStore/
/// KanbanStore). Семантика 1:1 с python discovery.py/db.py: INSERT со служебными дефолтами (draft/new/счётчики 0),
/// UPDATE присутствующих полей патча с bump UpdatedAt, атомарные инкременты счётчиков (bump_counter), upsert
/// чёрного списка (ON CONFLICT DO UPDATE name/reason при сохранённом CreatedAt), каскад delete_task
/// (задача + кандидаты + лог). JSON-колонки (keywords/marks/topics) — text с сериализованным JSON camelCase;
/// времена — timestamptz (DateTimeOffset), наружу epoch-ms. Чтение Dialogs (проверка «уже мониторится») — тот же
/// TenantDbContext (владелец каталога — модуль Telegram; доступ по БД, реверс-зависимостей нет).
/// C32: класс разделён на partial-файлы по агрегатам (DiscoveryStore.Tasks/Candidates/Blacklist/Logs.cs);
/// маппинг DTO ↔ строк остаётся в этом файле. Поведение и сигнатуры не менялись.
/// </remarks>
public sealed partial class DiscoveryStore : IDiscoveryStore
{
private readonly TenantDbContext _dbContext;
/// <summary>
/// Создаёт EF-адаптер хранилища Discovery (зависимости — tenant-контекст БД).
/// </summary>
/// <param name="dbContext">Scoped-контекст тенанта запроса (search_path).</param>
public DiscoveryStore(TenantDbContext dbContext)
{
_dbContext = dbContext;
}
// Опции JSON: camelCase для wire-форм Discovery (1:1 с §4.8; эталон KanbanStore JsonOptions).
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
// Маппит строку задачи в DTO (task_view L116–137; keywords — JSON-список, времена — epoch-ms).
private static DiscoveryTaskDto ToTaskDto(DiscTaskEntity row)
{
return new DiscoveryTaskDto(
row.Id,
row.Name,
row.Description,
FromJson<string>(row.KeywordsJson),
row.MinSubscribers,
row.Lang,
row.Threshold,
row.SampleSize,
row.PlanJoins,
row.AutoJoin,
row.Status,
row.SearchIdx,
row.SearchDone,
row.Found,
row.Evaluated,
row.Joined,
row.Rejected,
row.CreatedAt.ToUnixTimeMilliseconds(),
row.UpdatedAt.ToUnixTimeMilliseconds());
}
// Маппит строку кандидата в DTO (candidate_view L140–158; marks/topics — JSON-списки).
private static DiscoveryCandidateDto ToCandidateDto(DiscCandidateEntity row)
{
return new DiscoveryCandidateDto(
row.DialogId,
row.TaskId,
row.Name,
row.Username,
row.Kind,
row.Hue,
row.Participants,
row.LangRu,
FromJson<string>(row.MarksJson),
FromJson<DiscoveryTopicDto>(row.TopicsJson),
row.FitRatio,
row.Status,
row.AutoJoined,
row.JoinFailures,
row.CreatedAt.ToUnixTimeMilliseconds(),
row.UpdatedAt.ToUnixTimeMilliseconds());
}
// Маппит строку чёрного списка в DTO (blacklist_view L161–167).
private static DiscoveryBlacklistDto ToBlacklistDto(DiscBlacklistEntity row)
{
return new DiscoveryBlacklistDto(row.DialogId, row.Name, row.Reason, row.CreatedAt.ToUnixTimeMilliseconds());
}
// Маппит строку лога в DTO (log_view L170–177).
private static DiscoveryLogDto ToLogDto(DiscLogEntity row)
{
return new DiscoveryLogDto(row.Id, row.TaskId, row.Event, row.Text, row.CreatedAt.ToUnixTimeMilliseconds());
}
// Применяет патч задачи к строке (patch_task L292–310: только присутствующие поля; keywords — JSON).
private static void ApplyTaskPatch(DiscTaskEntity row, DiscoveryTaskPatch patch)
{
if (patch.Name is not null)
{
row.Name = patch.Name;
}
if (patch.Description is not null)
{
row.Description = patch.Description;
}
if (patch.Keywords is not null)
{
row.KeywordsJson = ToJson(patch.Keywords);
}
if (patch.MinSubscribers is int minSubscribers)
{
row.MinSubscribers = minSubscribers;
}
if (patch.Lang is not null)
{
row.Lang = patch.Lang;
}
if (patch.Threshold is int threshold)
{
row.Threshold = threshold;
}
if (patch.SampleSize is int sampleSize)
{
row.SampleSize = sampleSize;
}
if (patch.PlanJoins is int planJoins)
{
row.PlanJoins = planJoins;
}
if (patch.AutoJoin is bool autoJoin)
{
row.AutoJoin = autoJoin;
}
}
// Применяет патч кандидата к строке (set_candidate L468–492; marks/topics — JSON-замена).
private static void ApplyCandidatePatch(DiscCandidateEntity row, DiscoveryCandidatePatch patch)
{
if (patch.Name is not null)
{
row.Name = patch.Name;
}
if (patch.Username is not null)
{
row.Username = patch.Username;
}
if (patch.Kind is not null)
{
row.Kind = patch.Kind;
}
if (patch.Hue is not null)
{
row.Hue = patch.Hue;
}
if (patch.Participants is int participants)
{
row.Participants = participants;
}
if (patch.LangRu is bool langRu)
{
row.LangRu = langRu;
}
if (patch.Marks is not null)
{
row.MarksJson = ToJson(patch.Marks);
}
if (patch.Topics is not null)
{
row.TopicsJson = ToJson(patch.Topics);
}
if (patch.FitRatio is double fitRatio)
{
row.FitRatio = fitRatio;
}
if (patch.AutoJoined is bool autoJoined)
{
row.AutoJoined = autoJoined;
}
}
// Разбирает JSON-массив колонки (повреждённая строка/не-массив → пустой список, python _loads L73–77).
private static IReadOnlyList<T> FromJson<T>(string json)
{
try
{
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
// Сериализует значение в JSON (camelCase, конвенция value_json).
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
}
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
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 Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17).
/// </summary>
/// <remarks>
/// Реализация порта <see cref="IDiscoveryStore"/> на <see cref="TenantDbContext"/> (эталон TelegramStore/
/// KanbanStore). Семантика 1:1 с python discovery.py/db.py: INSERT со служебными дефолтами (draft/new/счётчики 0),
/// UPDATE присутствующих полей патча с bump UpdatedAt, атомарные инкременты счётчиков (bump_counter), upsert
/// чёрного списка (ON CONFLICT DO UPDATE name/reason при сохранённом CreatedAt), каскад delete_task
/// (задача + кандидаты + лог). JSON-колонки (keywords/marks/topics) — text с сериализованным JSON camelCase;
/// времена — timestamptz (DateTimeOffset), наружу epoch-ms. Чтение Dialogs (проверка «уже мониторится») — тот же
/// TenantDbContext (владелец каталога — модуль Telegram; доступ по БД, реверс-зависимостей нет).
/// C32: класс разделён на partial-файлы по агрегатам (DiscoveryStore.Tasks/Candidates/Blacklist/Logs.cs);
/// маппинг DTO ↔ строк остаётся в этом файле. Поведение и сигнатуры не менялись.
/// </remarks>
public sealed partial class DiscoveryStore : IDiscoveryStore
{
private readonly TenantDbContext _dbContext;
/// <summary>
/// Создаёт EF-адаптер хранилища Discovery (зависимости — tenant-контекст БД).
/// </summary>
/// <param name="dbContext">Scoped-контекст тенанта запроса (search_path).</param>
public DiscoveryStore(TenantDbContext dbContext)
{
_dbContext = dbContext;
}
// Опции JSON: camelCase для wire-форм Discovery (1:1 с §4.8; эталон KanbanStore JsonOptions).
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
// Маппит строку задачи в DTO (task_view L116–137; keywords — JSON-список, времена — epoch-ms).
private static DiscoveryTaskDto ToTaskDto(DiscTaskEntity row)
{
return new DiscoveryTaskDto(
row.Id,
row.Name,
row.Description,
FromJson<string>(row.KeywordsJson),
row.MinSubscribers,
row.Lang,
row.Threshold,
row.SampleSize,
row.PlanJoins,
row.AutoJoin,
row.Status,
row.SearchIdx,
row.SearchDone,
row.Found,
row.Evaluated,
row.Joined,
row.Rejected,
row.CreatedAt.ToUnixTimeMilliseconds(),
row.UpdatedAt.ToUnixTimeMilliseconds());
}
// Маппит строку кандидата в DTO (candidate_view L140–158; marks/topics — JSON-списки).
private static DiscoveryCandidateDto ToCandidateDto(DiscCandidateEntity row)
{
return new DiscoveryCandidateDto(
row.DialogId,
row.TaskId,
row.Name,
row.Username,
row.Kind,
row.Hue,
row.Participants,
row.LangRu,
FromJson<string>(row.MarksJson),
FromJson<DiscoveryTopicDto>(row.TopicsJson),
row.FitRatio,
row.Status,
row.AutoJoined,
row.JoinFailures,
row.CreatedAt.ToUnixTimeMilliseconds(),
row.UpdatedAt.ToUnixTimeMilliseconds());
}
// Маппит строку чёрного списка в DTO (blacklist_view L161–167).
private static DiscoveryBlacklistDto ToBlacklistDto(DiscBlacklistEntity row)
{
return new DiscoveryBlacklistDto(row.DialogId, row.Name, row.Reason, row.CreatedAt.ToUnixTimeMilliseconds());
}
// Маппит строку лога в DTO (log_view L170–177).
private static DiscoveryLogDto ToLogDto(DiscLogEntity row)
{
return new DiscoveryLogDto(row.Id, row.TaskId, row.Event, row.Text, row.CreatedAt.ToUnixTimeMilliseconds());
}
// Применяет патч задачи к строке (patch_task L292–310: только присутствующие поля; keywords — JSON).
private static void ApplyTaskPatch(DiscTaskEntity row, DiscoveryTaskPatch patch)
{
if (patch.Name is not null)
{
row.Name = patch.Name;
}
if (patch.Description is not null)
{
row.Description = patch.Description;
}
if (patch.Keywords is not null)
{
row.KeywordsJson = ToJson(patch.Keywords);
}
if (patch.MinSubscribers is int minSubscribers)
{
row.MinSubscribers = minSubscribers;
}
if (patch.Lang is not null)
{
row.Lang = patch.Lang;
}
if (patch.Threshold is int threshold)
{
row.Threshold = threshold;
}
if (patch.SampleSize is int sampleSize)
{
row.SampleSize = sampleSize;
}
if (patch.PlanJoins is int planJoins)
{
row.PlanJoins = planJoins;
}
if (patch.AutoJoin is bool autoJoin)
{
row.AutoJoin = autoJoin;
}
}
// Применяет патч кандидата к строке (set_candidate L468–492; marks/topics — JSON-замена).
private static void ApplyCandidatePatch(DiscCandidateEntity row, DiscoveryCandidatePatch patch)
{
if (patch.Name is not null)
{
row.Name = patch.Name;
}
if (patch.Username is not null)
{
row.Username = patch.Username;
}
if (patch.Kind is not null)
{
row.Kind = patch.Kind;
}
if (patch.Hue is not null)
{
row.Hue = patch.Hue;
}
if (patch.Participants is int participants)
{
row.Participants = participants;
}
if (patch.LangRu is bool langRu)
{
row.LangRu = langRu;
}
if (patch.Marks is not null)
{
row.MarksJson = ToJson(patch.Marks);
}
if (patch.Topics is not null)
{
row.TopicsJson = ToJson(patch.Topics);
}
if (patch.FitRatio is double fitRatio)
{
row.FitRatio = fitRatio;
}
if (patch.AutoJoined is bool autoJoined)
{
row.AutoJoined = autoJoined;
}
}
// Разбирает JSON-массив колонки (повреждённая строка/не-массив → пустой список, python _loads L73–77).
private static IReadOnlyList<T> FromJson<T>(string json)
{
try
{
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
// Сериализует значение в JSON (camelCase, конвенция value_json).
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
}
@@ -1,51 +1,53 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер KV-хранилища глобальных (системных) настроек оператора: таблица public.global_settings.
/// </summary>
/// <remarks>
/// Маппинг 1:1 со строкой таблицы: key/value/updated_at (сущность GlobalSettingEntity, системный
/// DealDbContext схемы public). Порт оперирует готовыми JSON-строками (сериализацию выполняет
/// владелец ключа), поэтому адаптер хранит значение как текст без интерпретации. <see cref="SetAsync"/>
/// пишет updated_at = UTC-now.
/// </remarks>
public sealed class GlobalSettingsStore(DealDbContext dbContext) : IGlobalSettingsStore
{
/// <inheritdoc />
public async Task<SettingValue?> GetAsync(string key, CancellationToken ct)
{
var entity = await dbContext.GlobalSettings
.AsNoTracking()
.SingleOrDefaultAsync(s => s.Key == key, ct);
return entity is null ? null : new SettingValue(entity.Key, entity.Value, entity.UpdatedAt);
}
/// <inheritdoc />
public async Task SetAsync(string key, string valueJson, CancellationToken ct)
{
// Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется.
GlobalSettingEntity? entity = await dbContext.GlobalSettings
.SingleOrDefaultAsync(s => s.Key == key, ct);
if (entity is null)
{
dbContext.GlobalSettings.Add(new GlobalSettingEntity
{
Key = key,
Value = valueJson,
UpdatedAt = DateTimeOffset.UtcNow,
});
}
else
{
entity.Value = valueJson;
entity.UpdatedAt = DateTimeOffset.UtcNow;
}
await dbContext.SaveChangesAsync(ct);
}
}
using Deal.Infrastructure.Persistence.Entities;
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 Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер KV-хранилища глобальных (системных) настроек оператора: таблица public.global_settings.
/// </summary>
/// <remarks>
/// Маппинг 1:1 со строкой таблицы: key/value/updated_at (сущность GlobalSettingEntity, системный
/// DealDbContext схемы public). Порт оперирует готовыми JSON-строками (сериализацию выполняет
/// владелец ключа), поэтому адаптер хранит значение как текст без интерпретации. <see cref="SetAsync"/>
/// пишет updated_at = UTC-now.
/// </remarks>
public sealed class GlobalSettingsStore(DealDbContext dbContext) : IGlobalSettingsStore
{
/// <inheritdoc />
public async Task<SettingValue?> GetAsync(string key, CancellationToken ct)
{
var entity = await dbContext.GlobalSettings
.AsNoTracking()
.SingleOrDefaultAsync(s => s.Key == key, ct);
return entity is null ? null : new SettingValue(entity.Key, entity.Value, entity.UpdatedAt);
}
/// <inheritdoc />
public async Task SetAsync(string key, string valueJson, CancellationToken ct)
{
// Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется.
GlobalSettingEntity? entity = await dbContext.GlobalSettings
.SingleOrDefaultAsync(s => s.Key == key, ct);
if (entity is null)
{
dbContext.GlobalSettings.Add(new GlobalSettingEntity
{
Key = key,
Value = valueJson,
UpdatedAt = DateTimeOffset.UtcNow,
});
}
else
{
entity.Value = valueJson;
entity.UpdatedAt = DateTimeOffset.UtcNow;
}
await dbContext.SaveChangesAsync(ct);
}
}
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,7 +1,10 @@
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,90 +1,93 @@
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// Комментарии и журнал действий — partial-часть <see cref="KanbanStore"/> (C32: выделено из общего
/// файла, поведение не менялось): LeadComments (список/добавление), CardMoves (запись/счётчик learning)
/// и few-shot-примеры разметки для ИИ (leads.py add_comment/_log_learning/_learning_examples).
/// </summary>
public sealed partial class KanbanStore
{
/// <inheritdoc />
public async Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct)
{
List<LeadCommentEntity> entities = await _dbContext.LeadComments
.AsNoTracking()
.Where(comment => comment.CardId == cardId)
.OrderBy(comment => comment.CreatedAt)
.ThenBy(comment => comment.Id)
.ToListAsync(ct);
return entities.Select(ToCommentDto).ToList();
}
/// <inheritdoc />
public async Task AddCommentAsync(string commentId, string cardId, string by, string text, CancellationToken ct)
{
// CreatedAt — UTC-now (leads.py add_comment L259–265). Целостность ссылки на карточку держит FK:
// комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис,
// прочитав карточку перед добавлением, — адаптеру возвращать нечего: порт void).
_dbContext.LeadComments.Add(new LeadCommentEntity
{
Id = commentId,
CardId = cardId,
By = by,
Text = text,
CreatedAt = DateTimeOffset.UtcNow,
});
await _dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task AddMoveAsync(CardMoveDto move, CancellationToken ct)
{
// Каждое действие пользователя пишет строку журнала (leads.py _log_learning L40–44); счётчик
// learning = число записей CardMoves (Ruling 4). CreatedAt — UTC-now.
_dbContext.CardMoves.Add(new CardMoveEntity
{
Id = move.Id,
LeadId = move.LeadId,
Action = move.Action,
FromCol = move.FromCol,
ToCol = move.ToCol,
CreatedAt = DateTimeOffset.UtcNow,
});
await _dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public Task<int> CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct);
/// <inheritdoc />
public async Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
{
// Few-shot-примеры ИИ-классификации (pipeline.py _learning_examples L201–215): join журнала с карточками,
// действия move/restore, цель не служебная (trash/archive), source_msg непустой, свежие первыми.
return await _dbContext.CardMoves
.AsNoTracking()
.Where(move => move.ToCol != null && move.ToCol != CardIds.Trash && move.ToCol != CardIds.Archive)
.Join(
_dbContext.Cards.AsNoTracking(),
move => move.LeadId,
card => card.Id,
(move, card) => new { move, card })
.Where(joined => (joined.move.Action == MoveAction || joined.move.Action == RestoreAction)
&& joined.card.SourceMsg.Length > 0)
.OrderByDescending(joined => joined.move.CreatedAt)
.Take(limit)
.Select(joined => new AiMarkupExampleDto(joined.card.SourceMsg, joined.move.ToCol!))
.ToListAsync(ct);
}
/// <inheritdoc />
}
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// Комментарии и журнал действий — partial-часть <see cref="KanbanStore"/> (C32: выделено из общего
/// файла, поведение не менялось): LeadComments (список/добавление), CardMoves (запись/счётчик learning)
/// и few-shot-примеры разметки для ИИ (leads.py add_comment/_log_learning/_learning_examples).
/// </summary>
public sealed partial class KanbanStore
{
/// <inheritdoc />
public async Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct)
{
List<LeadCommentEntity> entities = await _dbContext.LeadComments
.AsNoTracking()
.Where(comment => comment.CardId == cardId)
.OrderBy(comment => comment.CreatedAt)
.ThenBy(comment => comment.Id)
.ToListAsync(ct);
return entities.Select(ToCommentDto).ToList();
}
/// <inheritdoc />
public async Task AddCommentAsync(string commentId, string cardId, string by, string text, CancellationToken ct)
{
// CreatedAt — UTC-now (leads.py add_comment L259–265). Целостность ссылки на карточку держит FK:
// комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис,
// прочитав карточку перед добавлением, — адаптеру возвращать нечего: порт void).
_dbContext.LeadComments.Add(new LeadCommentEntity
{
Id = commentId,
CardId = cardId,
By = by,
Text = text,
CreatedAt = DateTimeOffset.UtcNow,
});
await _dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task AddMoveAsync(CardMoveDto move, CancellationToken ct)
{
// Каждое действие пользователя пишет строку журнала (leads.py _log_learning L40–44); счётчик
// learning = число записей CardMoves (Ruling 4). CreatedAt — UTC-now.
_dbContext.CardMoves.Add(new CardMoveEntity
{
Id = move.Id,
LeadId = move.LeadId,
Action = move.Action,
FromCol = move.FromCol,
ToCol = move.ToCol,
CreatedAt = DateTimeOffset.UtcNow,
});
await _dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public Task<int> CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct);
/// <inheritdoc />
public async Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
{
// Few-shot-примеры ИИ-классификации (pipeline.py _learning_examples L201–215): join журнала с карточками,
// действия move/restore, цель не служебная (trash/archive), source_msg непустой, свежие первыми.
return await _dbContext.CardMoves
.AsNoTracking()
.Where(move => move.ToCol != null && move.ToCol != CardIds.Trash && move.ToCol != CardIds.Archive)
.Join(
_dbContext.Cards.AsNoTracking(),
move => move.LeadId,
card => card.Id,
(move, card) => new { move, card })
.Where(joined => (joined.move.Action == MoveAction || joined.move.Action == RestoreAction)
&& joined.card.SourceMsg.Length > 0)
.OrderByDescending(joined => joined.move.CreatedAt)
.Take(limit)
.Select(joined => new AiMarkupExampleDto(joined.card.SourceMsg, joined.move.ToCol!))
.ToListAsync(ct);
}
/// <inheritdoc />
}
@@ -2,8 +2,11 @@ using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,144 +1,147 @@
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// Правила хранения, конверсии и вход эвристики suggest — partial-часть <see cref="KanbanStore"/>
/// (C32: выделено из общего файла, поведение не менялось): кандидаты тика (автоархив/очистка архива и
/// корзины), batch-архивация и жёсткое удаление пачки, пересчёт бюджетных конверсий и «Неразобранное»
/// с исходным текстом для ИИ-предложений (tick_storage/recompute_conversions/suggest, Rulings 3/7/8).
/// </summary>
public sealed partial class KanbanStore
{
/// <inheritdoc />
public async Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
{
// Кандидаты автоархива: карточки пользовательских колонок (kind=board) и «Неразобранного»
// (tick_storage L462–467) — колонки динамические, поэтому id колонок читаем реестром контейнеров.
List<string> boardIds = await _dbContext.Containers
.AsNoTracking()
.Where(container => container.Kind == ContainerKinds.Board)
.Select(container => container.Id)
.ToListAsync(ct);
return await _dbContext.Cards
.AsNoTracking()
.Where(card => card.ReceivedAt < receivedBeforeUtc
&& (card.Col == CardIds.Inbox || boardIds.Contains(card.Col)))
.Select(card => card.Id)
.ToListAsync(ct);
}
/// <inheritdoc />
public async Task<int> ArchiveAsync(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt, CancellationToken ct)
{
// Автоархив тика пачкой (tick_storage L462–473): один UPDATE вместо N по-карточных — как по-карточный
// UpdateColumnAsync автоархива: col=archive, is_new=false, archived_at=now, matchHits пусто;
// prev_col не трогается (Ruling 8). Возврат — сколько строк обновлено.
if (cardIds.Count == 0)
{
return 0;
}
return await _dbContext.Cards
.Where(card => cardIds.Contains(card.Id))
.ExecuteUpdateAsync(setters => setters
.SetProperty(card => card.Col, CardIds.Archive)
.SetProperty(card => card.IsNew, false)
.SetProperty(card => card.ArchivedAt, archivedAt)
.SetProperty(card => card.MatchHitsJson, ToJson(Array.Empty<MatchHitDto>())),
ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
{
// Очистка архива: col='archive' и archived_at старше срока (tick_storage L475–478).
return await _dbContext.Cards
.AsNoTracking()
.Where(card => card.Col == CardIds.Archive
&& card.ArchivedAt != null
&& card.ArchivedAt < archivedBeforeUtc)
.Select(card => card.Id)
.ToListAsync(ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
{
// Очистка корзины: col='trash' и received_at старше срока (tick_storage L480–483).
return await _dbContext.Cards
.AsNoTracking()
.Where(card => card.Col == CardIds.Trash && card.ReceivedAt < receivedBeforeUtc)
.Select(card => card.Id)
.ToListAsync(ct);
}
/// <inheritdoc />
public async Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
{
// Жёсткое удаление пачки (очистки тика и clear-col, Ruling 8): комментарии чистит каскад БД,
// строки дедупа удаляемых карточек — здесь же в транзакции (Ruling 3, _hard_delete L229).
if (cardIds.Count == 0)
{
return 0;
}
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
await _dbContext.DedupEntries
.Where(entry => entry.LeadId != null && cardIds.Contains(entry.LeadId!))
.ExecuteDeleteAsync(ct);
int deleted = await _dbContext.Cards
.Where(card => cardIds.Contains(card.Id))
.ExecuteDeleteAsync(ct);
await transaction.CommitAsync(ct);
return deleted;
}
/// <inheritdoc />
public async Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct)
{
// Карточки с бюджетом вне служебных колонок (recompute_conversions L106–130, Ruling 7):
// пересчёт читает только бюджетные/conv-поля, комментарии не нужны.
List<CardEntity> entities = await _dbContext.Cards
.AsNoTracking()
.Where(card => card.BudgetCur != string.Empty
&& !ConversionExcludedCols.Contains(card.Col))
.OrderByDescending(card => card.ReceivedAt)
.ToListAsync(ct);
return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList();
}
/// <inheritdoc />
public async Task UpdateConversionAsync(string cardId, double? convFrom, double? convTo, string convCur, CancellationToken ct)
{
// Пишутся только conv-поля карточки (Ruling 7); convCur пуст — конверсия снята.
await _dbContext.Cards
.Where(card => card.Id == cardId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(card => card.ConvFrom, convFrom)
.SetProperty(card => card.ConvTo, convTo)
.SetProperty(card => card.ConvCur, convCur),
ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
{
// Вход эвристики suggest (Ruling 3): «Неразобранное» с непустым source_msg — только тексты нужны.
List<CardEntity> entities = await _dbContext.Cards
.AsNoTracking()
.Where(card => card.Col == CardIds.Inbox && card.SourceMsg != string.Empty)
.OrderByDescending(card => card.ReceivedAt)
.ToListAsync(ct);
return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList();
}
}
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// Правила хранения, конверсии и вход эвристики suggest — partial-часть <see cref="KanbanStore"/>
/// (C32: выделено из общего файла, поведение не менялось): кандидаты тика (автоархив/очистка архива и
/// корзины), batch-архивация и жёсткое удаление пачки, пересчёт бюджетных конверсий и «Неразобранное»
/// с исходным текстом для ИИ-предложений (tick_storage/recompute_conversions/suggest, Rulings 3/7/8).
/// </summary>
public sealed partial class KanbanStore
{
/// <inheritdoc />
public async Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
{
// Кандидаты автоархива: карточки пользовательских колонок (kind=board) и «Неразобранного»
// (tick_storage L462–467) — колонки динамические, поэтому id колонок читаем реестром контейнеров.
List<string> boardIds = await _dbContext.Containers
.AsNoTracking()
.Where(container => container.Kind == ContainerKinds.Board)
.Select(container => container.Id)
.ToListAsync(ct);
return await _dbContext.Cards
.AsNoTracking()
.Where(card => card.ReceivedAt < receivedBeforeUtc
&& (card.Col == CardIds.Inbox || boardIds.Contains(card.Col)))
.Select(card => card.Id)
.ToListAsync(ct);
}
/// <inheritdoc />
public async Task<int> ArchiveAsync(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt, CancellationToken ct)
{
// Автоархив тика пачкой (tick_storage L462–473): один UPDATE вместо N по-карточных — как по-карточный
// UpdateColumnAsync автоархива: col=archive, is_new=false, archived_at=now, matchHits пусто;
// prev_col не трогается (Ruling 8). Возврат — сколько строк обновлено.
if (cardIds.Count == 0)
{
return 0;
}
return await _dbContext.Cards
.Where(card => cardIds.Contains(card.Id))
.ExecuteUpdateAsync(setters => setters
.SetProperty(card => card.Col, CardIds.Archive)
.SetProperty(card => card.IsNew, false)
.SetProperty(card => card.ArchivedAt, archivedAt)
.SetProperty(card => card.MatchHitsJson, ToJson(Array.Empty<MatchHitDto>())),
ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
{
// Очистка архива: col='archive' и archived_at старше срока (tick_storage L475–478).
return await _dbContext.Cards
.AsNoTracking()
.Where(card => card.Col == CardIds.Archive
&& card.ArchivedAt != null
&& card.ArchivedAt < archivedBeforeUtc)
.Select(card => card.Id)
.ToListAsync(ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
{
// Очистка корзины: col='trash' и received_at старше срока (tick_storage L480–483).
return await _dbContext.Cards
.AsNoTracking()
.Where(card => card.Col == CardIds.Trash && card.ReceivedAt < receivedBeforeUtc)
.Select(card => card.Id)
.ToListAsync(ct);
}
/// <inheritdoc />
public async Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
{
// Жёсткое удаление пачки (очистки тика и clear-col, Ruling 8): комментарии чистит каскад БД,
// строки дедупа удаляемых карточек — здесь же в транзакции (Ruling 3, _hard_delete L229).
if (cardIds.Count == 0)
{
return 0;
}
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
await _dbContext.DedupEntries
.Where(entry => entry.LeadId != null && cardIds.Contains(entry.LeadId!))
.ExecuteDeleteAsync(ct);
int deleted = await _dbContext.Cards
.Where(card => cardIds.Contains(card.Id))
.ExecuteDeleteAsync(ct);
await transaction.CommitAsync(ct);
return deleted;
}
/// <inheritdoc />
public async Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct)
{
// Карточки с бюджетом вне служебных колонок (recompute_conversions L106–130, Ruling 7):
// пересчёт читает только бюджетные/conv-поля, комментарии не нужны.
List<CardEntity> entities = await _dbContext.Cards
.AsNoTracking()
.Where(card => card.BudgetCur != string.Empty
&& !ConversionExcludedCols.Contains(card.Col))
.OrderByDescending(card => card.ReceivedAt)
.ToListAsync(ct);
return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList();
}
/// <inheritdoc />
public async Task UpdateConversionAsync(string cardId, double? convFrom, double? convTo, string convCur, CancellationToken ct)
{
// Пишутся только conv-поля карточки (Ruling 7); convCur пуст — конверсия снята.
await _dbContext.Cards
.Where(card => card.Id == cardId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(card => card.ConvFrom, convFrom)
.SetProperty(card => card.ConvTo, convTo)
.SetProperty(card => card.ConvCur, convCur),
ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
{
// Вход эвристики suggest (Ruling 3): «Неразобранное» с непустым source_msg — только тексты нужны.
List<CardEntity> entities = await _dbContext.Cards
.AsNoTracking()
.Where(card => card.Col == CardIds.Inbox && card.SourceMsg != string.Empty)
.OrderByDescending(card => card.ReceivedAt)
.ToListAsync(ct);
return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList();
}
}
@@ -1,320 +1,323 @@
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер хранилища карточек и контейнеров (Ruling 1/12): таблицы Cards/Containers/LeadComments/CardMoves схемы тенанта.
/// </summary>
/// <remarks>
/// Маппинг DTO ↔ строк выполняется вручную (эталон SettingsStore.cs): порт модуля не видит EF-сущности.
/// JSON-поля (KeywordsJson/VisibleFieldsJson/RulesJson, StackJson/ContactsJson/MatchHitsJson) хранятся текстом
/// с сериализованным JSON camelCase (конвенция value_json) — адаптер сериализует при записи и разбирает при чтении.
/// Времена — timestamptz (DateTimeOffset); наружу карточки отдают receivedAt epoch-ms (ToUnixTimeMilliseconds),
/// а human-метку «time» адаптер считает на лету от ReceivedAt/CreatedAt (Ruling 10, pipeline.py human_age L528–537).
/// Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) — здесь, как требует порт. Id записей генерирует
/// модуль (Ruling 12) — адаптер только сохраняет готовые id. Транзакции: одиночная запись — SaveChanges
/// (ExecuteUpdate/ExecuteDelete — одним statement'ом); методы с несколькими изменениями (DeleteContainerAsync:
/// карточки + доска; ReorderContainersAsync: позиции всех досок) обёрнуты в явную транзакцию.
/// Удаление карточки чистит комментарии каскадом БД (FK LeadComments → Cards, Ruling 1); журнал CardMoves
/// и MlOutbox при удалении карточек не трогаются (прототип _hard_delete). Строки DedupEntries карточки
/// (LeadId = id карточки) удаляются вместе с ней в той же транзакции (Ruling 3) — «сирота» не должна
/// блокировать повторное создание карточки; таблицы соседние в том же TenantDbContext, порт Pipeline
/// не привлекается (циклов модулей нет).
/// C32: класс разделён на partial-файлы по темам (KanbanStore.Containers/Cards/Selected/Comments/StorageRules.cs);
/// поведение, сигнатуры и тексты ошибок не менялись.
/// </remarks>
public sealed partial class KanbanStore : ICardStore
{
private readonly TenantDbContext _dbContext;
/// <summary>
/// Создаёт EF-адаптер хранилища канбана (зависимости — tenant-контекст БД).
/// </summary>
/// <param name="dbContext">Контекст схемы тенанта (Containers/Cards/LeadComments/CardMoves/DedupEntries).</param>
public KanbanStore(TenantDbContext dbContext)
{
_dbContext = dbContext;
}
// JSON-представление «правил нет» (в DTO — null Rules, Ruling 1: как value_json настроек).
private const string NoRulesJson = "{}";
// Действие журнала CardMoves «перенос» (источник few-shot-примеров, python move).
private const string MoveAction = "move";
// Действие журнала CardMoves «возврат из корзины/архива» (python restore).
private const string RestoreAction = "restore";
private const long MillisPerMinute = 60_000L;
private const long MinutesPerHour = 60L;
private const long HoursPerDay = 24L;
// Колонки, исключённые из пересчёта конверсий (recompute_conversions L106–130, Ruling 7).
private static readonly string[] ConversionExcludedCols =
[CardIds.Archive, CardIds.Trash];
// Id контейнеров-стадий пространства «Выбранные»: дашборд эти карточки не показывает (этап 9).
private static readonly string[] SelectedStageIds = CardsDefaultContainers.Ids.ToArray();
// Стадия «Отложено» — единственная, чьи напоминания проверяет проверка напоминаний (Ruling 3).
private const string HoldStage = CardsDefaultContainers.Hold;
// Опции JSON: camelCase для записей модуля Kanban (1:1 с wire-именами §4.1/§4.2).
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
// Читает карточки пачкой с приложенными комментариями (один запрос на все id).
// cards: Карточки (уже отсортированы запросом чтения).
// ct: Токен отмены.
// Возвращает: DTO карточек в порядке входа: JSON-поля разобраны, комментарии приложены, time посчитан.
private async Task<IReadOnlyList<CardDto>> ToCardDtosAsync(List<CardEntity> cards, CancellationToken ct)
{
if (cards.Count == 0)
{
return Array.Empty<CardDto>();
}
List<string> cardIds = cards.Select(card => card.Id).ToList();
List<LeadCommentEntity> comments = await _dbContext.LeadComments
.AsNoTracking()
.Where(comment => cardIds.Contains(comment.CardId))
.OrderBy(comment => comment.CreatedAt)
.ThenBy(comment => comment.Id)
.ToListAsync(ct);
ILookup<string, LeadCommentEntity> commentsByCardId = comments.ToLookup(comment => comment.CardId);
return cards
.Select(card => ToCardDto(card, commentsByCardId[card.Id].Select(ToCommentDto).ToList()))
.ToList();
}
// Маппинг строки Containers в DTO (JSON-поля разбираются).
private static ContainerDto ToContainerDto(ContainerEntity entity) => new()
{
Id = entity.Id,
Name = entity.Name,
Description = entity.Description,
Color = entity.Color,
Order = entity.Position,
Space = entity.Space,
Kind = entity.Kind,
Collapsed = entity.Collapsed,
Suggested = entity.Suggested,
Rules = ToRulesOrNull(entity.RulesJson),
Policy = ToPolicyOrEmpty(entity.PolicyJson),
Note = entity.Note,
};
// Маппинг DTO в строку Containers (JSON-поля сериализуются; CreatedAt — UTC-now).
private static ContainerEntity ToContainerEntity(ContainerDto container) => new()
{
Id = container.Id,
Name = container.Name,
Description = container.Description,
Color = container.Color,
Position = container.Order,
Space = container.Space,
Kind = container.Kind,
Collapsed = container.Collapsed,
Suggested = container.Suggested,
RulesJson = ToRulesJson(container.Rules),
PolicyJson = ToPolicyJson(container.Policy),
Note = container.Note,
CreatedAt = DateTimeOffset.UtcNow,
};
// Маппинг строки Cards в DTO: JSON-поля разобраны, budget/converted собраны из пар (from,to,cur).
// entity: Строка карточки.
// comments: Комментарии карточки (уже в DTO с human-меткой time).
private static CardDto ToCardDto(CardEntity entity, IReadOnlyList<CardCommentDto> comments) => new()
{
Id = entity.Id,
Col = entity.Col,
IsNew = entity.IsNew,
Local = entity.Local,
IsVacancy = entity.IsVacancy,
IsVacancyKnown = entity.IsVacancyKnown,
Title = entity.Title,
Summary = entity.Summary,
Source = new CardSourceDto(
Kind: ResolveSourceKind(entity),
DisplayName: entity.ChannelName,
OriginRef: entity.SourceDialogId,
ReceivedAt: entity.ReceivedAt.ToUnixTimeMilliseconds()),
Stack = ToJsonList<string>(entity.StackJson),
Budget = entity.BudgetCur.Length == 0
? null
: new CardBudgetDto(entity.BudgetFrom, entity.BudgetTo, entity.BudgetCur),
Converted = entity.ConvCur.Length == 0
? null
: new CardBudgetDto(entity.ConvFrom, entity.ConvTo, entity.ConvCur),
Contact = entity.Contact,
Contacts = ToJsonList<CardContactDto>(entity.ContactsJson),
Channel = new CardChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue),
Time = HumanAge(entity.ReceivedAt),
ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(),
SourceMsg = entity.SourceMsg,
SourceDialogId = entity.SourceDialogId,
SourceMsgId = entity.SourceMsgId,
PrevCol = entity.PrevCol,
MatchHits = ToJsonList<MatchHitDto>(entity.MatchHitsJson),
Comments = comments,
Links = ToJsonList<CardLinkDto>(entity.LinksJson),
Files = ToJsonList<CardFileDto>(entity.FilesJson),
History = ToJsonList<CardHistoryDto>(entity.HistoryJson),
TzText = entity.TzText,
Reminder = entity.ReminderAt is { } reminderAt ? new CardReminderDto(reminderAt.ToUnixTimeMilliseconds()) : null,
CreatedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(),
UpdatedAtMs = entity.UpdatedAt.ToUnixTimeMilliseconds(),
};
// Вид источника карточки (производная проекция): local/telegram/other.
// entity: Строка карточки.
// Возвращает: Вид источника (строка wire-контракта).
private static string ResolveSourceKind(CardEntity entity)
{
if (entity.Local)
{
return "local";
}
return entity.ChannelName.Length > 0 || entity.SourceDialogId.Length > 0 ? "telegram" : "other";
}
// Маппинг снимка (write-модель создания карточки) в строку Cards; CreatedAt — UTC-now.
private static CardEntity ToCardEntity(CardSnapshot snapshot) => new()
{
Id = snapshot.Id,
Col = snapshot.Col,
IsNew = snapshot.IsNew,
Local = snapshot.Local,
IsVacancy = snapshot.IsVacancy,
IsVacancyKnown = snapshot.IsVacancyKnown,
Title = snapshot.Title,
Summary = snapshot.Summary,
StackJson = ToJson(snapshot.Stack),
BudgetFrom = snapshot.BudgetFrom,
BudgetTo = snapshot.BudgetTo,
BudgetCur = snapshot.BudgetCur,
ConvFrom = snapshot.ConvFrom,
ConvTo = snapshot.ConvTo,
ConvCur = snapshot.ConvCur,
Contact = snapshot.Contact,
ContactsJson = ToJson(snapshot.Contacts),
ChannelName = snapshot.ChannelName,
ChannelHandle = snapshot.ChannelHandle,
ChannelHue = snapshot.ChannelHue,
ReceivedAt = snapshot.ReceivedAt,
SourceMsg = snapshot.SourceMsg,
SourceDialogId = snapshot.SourceDialogId,
SourceMsgId = snapshot.SourceMsgId,
PrevCol = snapshot.PrevCol,
ArchivedAt = snapshot.ArchivedAt,
MatchHitsJson = ToJson(snapshot.MatchHits),
TzText = snapshot.TzText,
HistoryJson = ToJson(snapshot.History),
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
};
// Маппинг строки LeadComments в DTO (human-метка time от CreatedAt, Ruling 10).
private static CardCommentDto ToCommentDto(LeadCommentEntity entity) =>
new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt));
// Человеческая метка возраста: «только что»/«N мин»/«N ч»/«N дн» (Ruling 10, pipeline.py human_age L528–537).
// timestamp: Время события (получения сообщения/добавления комментария).
// Возвращает: Метка от текущего UTC-момента; будущее время даёт «только что» (delta зажат в 0, как в прототипе).
private static string HumanAge(DateTimeOffset timestamp)
{
long deltaMs = Math.Max(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestamp.ToUnixTimeMilliseconds());
long minutes = deltaMs / MillisPerMinute;
if (minutes < MinutesPerHour)
{
return minutes < 1 ? CardsService.JustNowLabel : $"{minutes} мин";
}
long hours = minutes / MinutesPerHour;
if (hours < HoursPerDay)
{
return $"{hours} ч";
}
long days = hours / HoursPerDay;
return $"{days} дн";
}
// Разбирает JSON-массив в список; пустая/битая строка — пустой список (как json.loads в прототипе).
private static IReadOnlyList<T> ToJsonList<T>(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return Array.Empty<T>();
}
try
{
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
}
catch (JsonException)
{
return Array.Empty<T>();
}
}
// Разбирает rules-объект: {}/пустая/битая строка — null («правил нет»).
private static ContainerRulesDto? ToRulesOrNull(string json)
{
if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson)
{
return null;
}
try
{
return JsonSerializer.Deserialize<ContainerRulesDto>(json, JsonOptions);
}
catch (JsonException)
{
return null;
}
}
// Сериализует rules-объект: null («правил нет») хранится как {}.
private static string ToRulesJson(ContainerRulesDto? rules) =>
rules is null ? NoRulesJson : JsonSerializer.Serialize(rules, JsonOptions);
// Разбирает policy-объект: пустая/битая строка — политика по умолчанию (обычная колонка).
private static ContainerPolicyDto ToPolicyOrEmpty(string json)
{
if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson)
{
return new ContainerPolicyDto();
}
try
{
return JsonSerializer.Deserialize<ContainerPolicyDto>(json, JsonOptions) ?? new ContainerPolicyDto();
}
catch (JsonException)
{
return new ContainerPolicyDto();
}
}
// Сериализует policy-объект в JSON (camelCase, конвенция value_json).
private static string ToPolicyJson(ContainerPolicyDto policy) => JsonSerializer.Serialize(policy, JsonOptions);
// Сериализует значение в JSON (camelCase, конвенция value_json).
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
}
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер хранилища карточек и контейнеров (Ruling 1/12): таблицы Cards/Containers/LeadComments/CardMoves схемы тенанта.
/// </summary>
/// <remarks>
/// Маппинг DTO ↔ строк выполняется вручную (эталон SettingsStore.cs): порт модуля не видит EF-сущности.
/// JSON-поля (KeywordsJson/VisibleFieldsJson/RulesJson, StackJson/ContactsJson/MatchHitsJson) хранятся текстом
/// с сериализованным JSON camelCase (конвенция value_json) — адаптер сериализует при записи и разбирает при чтении.
/// Времена — timestamptz (DateTimeOffset); наружу карточки отдают receivedAt epoch-ms (ToUnixTimeMilliseconds),
/// а human-метку «time» адаптер считает на лету от ReceivedAt/CreatedAt (Ruling 10, pipeline.py human_age L528–537).
/// Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) — здесь, как требует порт. Id записей генерирует
/// модуль (Ruling 12) — адаптер только сохраняет готовые id. Транзакции: одиночная запись — SaveChanges
/// (ExecuteUpdate/ExecuteDelete — одним statement'ом); методы с несколькими изменениями (DeleteContainerAsync:
/// карточки + доска; ReorderContainersAsync: позиции всех досок) обёрнуты в явную транзакцию.
/// Удаление карточки чистит комментарии каскадом БД (FK LeadComments → Cards, Ruling 1); журнал CardMoves
/// и MlOutbox при удалении карточек не трогаются (прототип _hard_delete). Строки DedupEntries карточки
/// (LeadId = id карточки) удаляются вместе с ней в той же транзакции (Ruling 3) — «сирота» не должна
/// блокировать повторное создание карточки; таблицы соседние в том же TenantDbContext, порт Pipeline
/// не привлекается (циклов модулей нет).
/// C32: класс разделён на partial-файлы по темам (KanbanStore.Containers/Cards/Selected/Comments/StorageRules.cs);
/// поведение, сигнатуры и тексты ошибок не менялись.
/// </remarks>
public sealed partial class KanbanStore : ICardStore
{
private readonly TenantDbContext _dbContext;
/// <summary>
/// Создаёт EF-адаптер хранилища канбана (зависимости — tenant-контекст БД).
/// </summary>
/// <param name="dbContext">Контекст схемы тенанта (Containers/Cards/LeadComments/CardMoves/DedupEntries).</param>
public KanbanStore(TenantDbContext dbContext)
{
_dbContext = dbContext;
}
// JSON-представление «правил нет» (в DTO — null Rules, Ruling 1: как value_json настроек).
private const string NoRulesJson = "{}";
// Действие журнала CardMoves «перенос» (источник few-shot-примеров, python move).
private const string MoveAction = "move";
// Действие журнала CardMoves «возврат из корзины/архива» (python restore).
private const string RestoreAction = "restore";
private const long MillisPerMinute = 60_000L;
private const long MinutesPerHour = 60L;
private const long HoursPerDay = 24L;
// Колонки, исключённые из пересчёта конверсий (recompute_conversions L106–130, Ruling 7).
private static readonly string[] ConversionExcludedCols =
[CardIds.Archive, CardIds.Trash];
// Id контейнеров-стадий пространства «Выбранные»: дашборд эти карточки не показывает (этап 9).
private static readonly string[] SelectedStageIds = CardsDefaultContainers.Ids.ToArray();
// Стадия «Отложено» — единственная, чьи напоминания проверяет проверка напоминаний (Ruling 3).
private const string HoldStage = CardsDefaultContainers.Hold;
// Опции JSON: camelCase для записей модуля Kanban (1:1 с wire-именами §4.1/§4.2).
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
// Читает карточки пачкой с приложенными комментариями (один запрос на все id).
// cards: Карточки (уже отсортированы запросом чтения).
// ct: Токен отмены.
// Возвращает: DTO карточек в порядке входа: JSON-поля разобраны, комментарии приложены, time посчитан.
private async Task<IReadOnlyList<CardDto>> ToCardDtosAsync(List<CardEntity> cards, CancellationToken ct)
{
if (cards.Count == 0)
{
return Array.Empty<CardDto>();
}
List<string> cardIds = cards.Select(card => card.Id).ToList();
List<LeadCommentEntity> comments = await _dbContext.LeadComments
.AsNoTracking()
.Where(comment => cardIds.Contains(comment.CardId))
.OrderBy(comment => comment.CreatedAt)
.ThenBy(comment => comment.Id)
.ToListAsync(ct);
ILookup<string, LeadCommentEntity> commentsByCardId = comments.ToLookup(comment => comment.CardId);
return cards
.Select(card => ToCardDto(card, commentsByCardId[card.Id].Select(ToCommentDto).ToList()))
.ToList();
}
// Маппинг строки Containers в DTO (JSON-поля разбираются).
private static ContainerDto ToContainerDto(ContainerEntity entity) => new()
{
Id = entity.Id,
Name = entity.Name,
Description = entity.Description,
Color = entity.Color,
Order = entity.Position,
Space = entity.Space,
Kind = entity.Kind,
Collapsed = entity.Collapsed,
Suggested = entity.Suggested,
Rules = ToRulesOrNull(entity.RulesJson),
Policy = ToPolicyOrEmpty(entity.PolicyJson),
Note = entity.Note,
};
// Маппинг DTO в строку Containers (JSON-поля сериализуются; CreatedAt — UTC-now).
private static ContainerEntity ToContainerEntity(ContainerDto container) => new()
{
Id = container.Id,
Name = container.Name,
Description = container.Description,
Color = container.Color,
Position = container.Order,
Space = container.Space,
Kind = container.Kind,
Collapsed = container.Collapsed,
Suggested = container.Suggested,
RulesJson = ToRulesJson(container.Rules),
PolicyJson = ToPolicyJson(container.Policy),
Note = container.Note,
CreatedAt = DateTimeOffset.UtcNow,
};
// Маппинг строки Cards в DTO: JSON-поля разобраны, budget/converted собраны из пар (from,to,cur).
// entity: Строка карточки.
// comments: Комментарии карточки (уже в DTO с human-меткой time).
private static CardDto ToCardDto(CardEntity entity, IReadOnlyList<CardCommentDto> comments) => new()
{
Id = entity.Id,
Col = entity.Col,
IsNew = entity.IsNew,
Local = entity.Local,
IsVacancy = entity.IsVacancy,
IsVacancyKnown = entity.IsVacancyKnown,
Title = entity.Title,
Summary = entity.Summary,
Source = new CardSourceDto(
Kind: ResolveSourceKind(entity),
DisplayName: entity.ChannelName,
OriginRef: entity.SourceDialogId,
ReceivedAt: entity.ReceivedAt.ToUnixTimeMilliseconds()),
Stack = ToJsonList<string>(entity.StackJson),
Budget = entity.BudgetCur.Length == 0
? null
: new CardBudgetDto(entity.BudgetFrom, entity.BudgetTo, entity.BudgetCur),
Converted = entity.ConvCur.Length == 0
? null
: new CardBudgetDto(entity.ConvFrom, entity.ConvTo, entity.ConvCur),
Contact = entity.Contact,
Contacts = ToJsonList<CardContactDto>(entity.ContactsJson),
Channel = new CardChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue),
Time = HumanAge(entity.ReceivedAt),
ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(),
SourceMsg = entity.SourceMsg,
SourceDialogId = entity.SourceDialogId,
SourceMsgId = entity.SourceMsgId,
PrevCol = entity.PrevCol,
MatchHits = ToJsonList<MatchHitDto>(entity.MatchHitsJson),
Comments = comments,
Links = ToJsonList<CardLinkDto>(entity.LinksJson),
Files = ToJsonList<CardFileDto>(entity.FilesJson),
History = ToJsonList<CardHistoryDto>(entity.HistoryJson),
TzText = entity.TzText,
Reminder = entity.ReminderAt is { } reminderAt ? new CardReminderDto(reminderAt.ToUnixTimeMilliseconds()) : null,
CreatedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(),
UpdatedAtMs = entity.UpdatedAt.ToUnixTimeMilliseconds(),
};
// Вид источника карточки (производная проекция): local/telegram/other.
// entity: Строка карточки.
// Возвращает: Вид источника (строка wire-контракта).
private static string ResolveSourceKind(CardEntity entity)
{
if (entity.Local)
{
return "local";
}
return entity.ChannelName.Length > 0 || entity.SourceDialogId.Length > 0 ? "telegram" : "other";
}
// Маппинг снимка (write-модель создания карточки) в строку Cards; CreatedAt — UTC-now.
private static CardEntity ToCardEntity(CardSnapshot snapshot) => new()
{
Id = snapshot.Id,
Col = snapshot.Col,
IsNew = snapshot.IsNew,
Local = snapshot.Local,
IsVacancy = snapshot.IsVacancy,
IsVacancyKnown = snapshot.IsVacancyKnown,
Title = snapshot.Title,
Summary = snapshot.Summary,
StackJson = ToJson(snapshot.Stack),
BudgetFrom = snapshot.BudgetFrom,
BudgetTo = snapshot.BudgetTo,
BudgetCur = snapshot.BudgetCur,
ConvFrom = snapshot.ConvFrom,
ConvTo = snapshot.ConvTo,
ConvCur = snapshot.ConvCur,
Contact = snapshot.Contact,
ContactsJson = ToJson(snapshot.Contacts),
ChannelName = snapshot.ChannelName,
ChannelHandle = snapshot.ChannelHandle,
ChannelHue = snapshot.ChannelHue,
ReceivedAt = snapshot.ReceivedAt,
SourceMsg = snapshot.SourceMsg,
SourceDialogId = snapshot.SourceDialogId,
SourceMsgId = snapshot.SourceMsgId,
PrevCol = snapshot.PrevCol,
ArchivedAt = snapshot.ArchivedAt,
MatchHitsJson = ToJson(snapshot.MatchHits),
TzText = snapshot.TzText,
HistoryJson = ToJson(snapshot.History),
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
};
// Маппинг строки LeadComments в DTO (human-метка time от CreatedAt, Ruling 10).
private static CardCommentDto ToCommentDto(LeadCommentEntity entity) =>
new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt));
// Человеческая метка возраста: «только что»/«N мин»/«N ч»/«N дн» (Ruling 10, pipeline.py human_age L528–537).
// timestamp: Время события (получения сообщения/добавления комментария).
// Возвращает: Метка от текущего UTC-момента; будущее время даёт «только что» (delta зажат в 0, как в прототипе).
private static string HumanAge(DateTimeOffset timestamp)
{
long deltaMs = Math.Max(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestamp.ToUnixTimeMilliseconds());
long minutes = deltaMs / MillisPerMinute;
if (minutes < MinutesPerHour)
{
return minutes < 1 ? CardsService.JustNowLabel : $"{minutes} мин";
}
long hours = minutes / MinutesPerHour;
if (hours < HoursPerDay)
{
return $"{hours} ч";
}
long days = hours / HoursPerDay;
return $"{days} дн";
}
// Разбирает JSON-массив в список; пустая/битая строка — пустой список (как json.loads в прототипе).
private static IReadOnlyList<T> ToJsonList<T>(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return Array.Empty<T>();
}
try
{
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
}
catch (JsonException)
{
return Array.Empty<T>();
}
}
// Разбирает rules-объект: {}/пустая/битая строка — null («правил нет»).
private static ContainerRulesDto? ToRulesOrNull(string json)
{
if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson)
{
return null;
}
try
{
return JsonSerializer.Deserialize<ContainerRulesDto>(json, JsonOptions);
}
catch (JsonException)
{
return null;
}
}
// Сериализует rules-объект: null («правил нет») хранится как {}.
private static string ToRulesJson(ContainerRulesDto? rules) =>
rules is null ? NoRulesJson : JsonSerializer.Serialize(rules, JsonOptions);
// Разбирает policy-объект: пустая/битая строка — политика по умолчанию (обычная колонка).
private static ContainerPolicyDto ToPolicyOrEmpty(string json)
{
if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson)
{
return new ContainerPolicyDto();
}
try
{
return JsonSerializer.Deserialize<ContainerPolicyDto>(json, JsonOptions) ?? new ContainerPolicyDto();
}
catch (JsonException)
{
return new ContainerPolicyDto();
}
}
// Сериализует policy-объект в JSON (camelCase, конвенция value_json).
private static string ToPolicyJson(ContainerPolicyDto policy) => JsonSerializer.Serialize(policy, JsonOptions);
// Сериализует значение в JSON (camelCase, конвенция value_json).
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
}
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,7 +1,13 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Pipeline.Application;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Pipeline.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -2,7 +2,11 @@ using System.Data;
using System.Data.Common;
using System.Globalization;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,6 +1,8 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Settings.Application;
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 Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,183 +1,186 @@
using System.Linq.Expressions;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер истории расхода токенов: таблица public.token_usage_events (append-only + агрегаты, этап 10, T2).
/// </summary>
/// <remarks>
/// Маппинг DTO ↔ сущности вручную (порт модуля не видит EF-сущности, Ruling 1). Запись — только Add+SaveChanges.
/// Агрегация — group by день UTC (Year/Month/Day), тенант, провайдер или модель; фильтры TenantId/Provider/Model/
/// Kind/At-range. Порядок: day — по возрастанию даты, остальные — по убыванию total (детерминизм витрины).
/// Неизвестный groupBy — <see cref="ArgumentException"/> (HTTP-слой валидирует и отвечает 400 раньше).
/// </remarks>
public sealed class TokenUsageEventStore(DealDbContext dbContext) : ITokenUsageEventStore
{
/// <inheritdoc />
public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct)
{
dbContext.TokenUsageEvents.Add(new TokenUsageEventEntity
{
// Id генерирует БД (identity) — из DTO не копируется.
TenantId = record.TenantId,
At = record.At,
Provider = record.Provider,
Model = record.Model,
Kind = record.Kind,
PromptTokens = record.PromptTokens,
CompletionTokens = record.CompletionTokens,
TotalTokens = record.TotalTokens,
DetailJson = record.DetailJson,
});
await dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct)
{
IQueryable<TokenUsageEventEntity> source = ApplyFilters(dbContext.TokenUsageEvents.AsNoTracking(), query);
return query.GroupBy switch
{
TokenUsageGroupBys.Day => await AggregateByDayAsync(source, ct),
TokenUsageGroupBys.Tenant => await AggregateByTenantAsync(source, ct),
TokenUsageGroupBys.Provider => await AggregateByStringAsync(source, entity => entity.Provider, ct),
TokenUsageGroupBys.Model => await AggregateByStringAsync(source, entity => entity.Model, ct),
_ => throw new ArgumentException($"Неизвестная группировка расхода токенов: '{query.GroupBy}'.", nameof(query)),
};
}
// Применяет фильтры агрегации (TenantId/Provider/Model/Kind/At-range).
// source: Базовый запрос.
// query: Фильтр/группировка.
// Возвращает: Запрос с фильтрами.
private static IQueryable<TokenUsageEventEntity> ApplyFilters(IQueryable<TokenUsageEventEntity> source, TokenUsageEventQueryDto query)
{
if (query.TenantId is not null)
{
source = source.Where(e => e.TenantId == query.TenantId);
}
if (!string.IsNullOrWhiteSpace(query.Provider))
{
source = source.Where(e => e.Provider == query.Provider);
}
if (!string.IsNullOrWhiteSpace(query.Model))
{
source = source.Where(e => e.Model == query.Model);
}
if (!string.IsNullOrWhiteSpace(query.Kind))
{
source = source.Where(e => e.Kind == query.Kind);
}
if (query.From is not null)
{
source = source.Where(e => e.At >= query.From.Value);
}
if (query.To is not null)
{
source = source.Where(e => e.At <= query.To.Value);
}
return source;
}
// Агрегат по суткам UTC (ключ ГГГГ-ММ-ДД), порядок — по возрастанию даты.
// source: Отфильтрованный запрос.
// ct: Токен отмены.
// Возвращает: Строки агрегатов по дням.
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByDayAsync(
IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
{
var rows = await source
.GroupBy(e => new { e.At.Year, e.At.Month, e.At.Day })
.Select(group => new
{
group.Key.Year,
group.Key.Month,
group.Key.Day,
Prompt = group.Sum(x => x.PromptTokens),
Completion = group.Sum(x => x.CompletionTokens),
Total = group.Sum(x => x.TotalTokens),
Count = group.LongCount(),
})
.ToListAsync(ct);
return rows
.OrderBy(row => row.Year)
.ThenBy(row => row.Month)
.ThenBy(row => row.Day)
.Select(row => new TokenUsageAggregateDto(
$"{row.Year:D4}-{row.Month:D2}-{row.Day:D2}",
row.Prompt,
row.Completion,
row.Total,
row.Count))
.ToList();
}
// Агрегат по тенантам (ключ — Guid "D"), порядок — по убыванию total.
// source: Отфильтрованный запрос.
// ct: Токен отмены.
// Возвращает: Строки агрегатов по тенантам.
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByTenantAsync(
IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
{
var rows = await source
.GroupBy(e => e.TenantId)
.Select(group => new
{
group.Key,
Prompt = group.Sum(x => x.PromptTokens),
Completion = group.Sum(x => x.CompletionTokens),
Total = group.Sum(x => x.TotalTokens),
Count = group.LongCount(),
})
.ToListAsync(ct);
return rows
.OrderByDescending(row => row.Total)
.Select(row => new TokenUsageAggregateDto(
row.Key.ToString("D"),
row.Prompt,
row.Completion,
row.Total,
row.Count))
.ToList();
}
// Агрегат по строковому ключу (провайдер/модель), порядок — по убыванию total.
// source: Отфильтрованный запрос.
// keySelector: Селектор ключа группы (провайдер/модель).
// ct: Токен отмены.
// Возвращает: Строки агрегатов по ключу.
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByStringAsync(
IQueryable<TokenUsageEventEntity> source,
Expression<Func<TokenUsageEventEntity, string>> keySelector,
CancellationToken ct)
{
var rows = await source
.GroupBy(keySelector)
.Select(group => new
{
Key = group.Key,
Prompt = group.Sum(x => x.PromptTokens),
Completion = group.Sum(x => x.CompletionTokens),
Total = group.Sum(x => x.TotalTokens),
Count = group.LongCount(),
})
.ToListAsync(ct);
return rows
.OrderByDescending(row => row.Total)
.Select(row => new TokenUsageAggregateDto(row.Key, row.Prompt, row.Completion, row.Total, row.Count))
.ToList();
}
}
using System.Linq.Expressions;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер истории расхода токенов: таблица public.token_usage_events (append-only + агрегаты, этап 10, T2).
/// </summary>
/// <remarks>
/// Маппинг DTO ↔ сущности вручную (порт модуля не видит EF-сущности, Ruling 1). Запись — только Add+SaveChanges.
/// Агрегация — group by день UTC (Year/Month/Day), тенант, провайдер или модель; фильтры TenantId/Provider/Model/
/// Kind/At-range. Порядок: day — по возрастанию даты, остальные — по убыванию total (детерминизм витрины).
/// Неизвестный groupBy — <see cref="ArgumentException"/> (HTTP-слой валидирует и отвечает 400 раньше).
/// </remarks>
public sealed class TokenUsageEventStore(DealDbContext dbContext) : ITokenUsageEventStore
{
/// <inheritdoc />
public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct)
{
dbContext.TokenUsageEvents.Add(new TokenUsageEventEntity
{
// Id генерирует БД (identity) — из DTO не копируется.
TenantId = record.TenantId,
At = record.At,
Provider = record.Provider,
Model = record.Model,
Kind = record.Kind,
PromptTokens = record.PromptTokens,
CompletionTokens = record.CompletionTokens,
TotalTokens = record.TotalTokens,
DetailJson = record.DetailJson,
});
await dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct)
{
IQueryable<TokenUsageEventEntity> source = ApplyFilters(dbContext.TokenUsageEvents.AsNoTracking(), query);
return query.GroupBy switch
{
TokenUsageGroupBys.Day => await AggregateByDayAsync(source, ct),
TokenUsageGroupBys.Tenant => await AggregateByTenantAsync(source, ct),
TokenUsageGroupBys.Provider => await AggregateByStringAsync(source, entity => entity.Provider, ct),
TokenUsageGroupBys.Model => await AggregateByStringAsync(source, entity => entity.Model, ct),
_ => throw new ArgumentException($"Неизвестная группировка расхода токенов: '{query.GroupBy}'.", nameof(query)),
};
}
// Применяет фильтры агрегации (TenantId/Provider/Model/Kind/At-range).
// source: Базовый запрос.
// query: Фильтр/группировка.
// Возвращает: Запрос с фильтрами.
private static IQueryable<TokenUsageEventEntity> ApplyFilters(IQueryable<TokenUsageEventEntity> source, TokenUsageEventQueryDto query)
{
if (query.TenantId is not null)
{
source = source.Where(e => e.TenantId == query.TenantId);
}
if (!string.IsNullOrWhiteSpace(query.Provider))
{
source = source.Where(e => e.Provider == query.Provider);
}
if (!string.IsNullOrWhiteSpace(query.Model))
{
source = source.Where(e => e.Model == query.Model);
}
if (!string.IsNullOrWhiteSpace(query.Kind))
{
source = source.Where(e => e.Kind == query.Kind);
}
if (query.From is not null)
{
source = source.Where(e => e.At >= query.From.Value);
}
if (query.To is not null)
{
source = source.Where(e => e.At <= query.To.Value);
}
return source;
}
// Агрегат по суткам UTC (ключ ГГГГ-ММ-ДД), порядок — по возрастанию даты.
// source: Отфильтрованный запрос.
// ct: Токен отмены.
// Возвращает: Строки агрегатов по дням.
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByDayAsync(
IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
{
var rows = await source
.GroupBy(e => new { e.At.Year, e.At.Month, e.At.Day })
.Select(group => new
{
group.Key.Year,
group.Key.Month,
group.Key.Day,
Prompt = group.Sum(x => x.PromptTokens),
Completion = group.Sum(x => x.CompletionTokens),
Total = group.Sum(x => x.TotalTokens),
Count = group.LongCount(),
})
.ToListAsync(ct);
return rows
.OrderBy(row => row.Year)
.ThenBy(row => row.Month)
.ThenBy(row => row.Day)
.Select(row => new TokenUsageAggregateDto(
$"{row.Year:D4}-{row.Month:D2}-{row.Day:D2}",
row.Prompt,
row.Completion,
row.Total,
row.Count))
.ToList();
}
// Агрегат по тенантам (ключ — Guid "D"), порядок — по убыванию total.
// source: Отфильтрованный запрос.
// ct: Токен отмены.
// Возвращает: Строки агрегатов по тенантам.
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByTenantAsync(
IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
{
var rows = await source
.GroupBy(e => e.TenantId)
.Select(group => new
{
group.Key,
Prompt = group.Sum(x => x.PromptTokens),
Completion = group.Sum(x => x.CompletionTokens),
Total = group.Sum(x => x.TotalTokens),
Count = group.LongCount(),
})
.ToListAsync(ct);
return rows
.OrderByDescending(row => row.Total)
.Select(row => new TokenUsageAggregateDto(
row.Key.ToString("D"),
row.Prompt,
row.Completion,
row.Total,
row.Count))
.ToList();
}
// Агрегат по строковому ключу (провайдер/модель), порядок — по убыванию total.
// source: Отфильтрованный запрос.
// keySelector: Селектор ключа группы (провайдер/модель).
// ct: Токен отмены.
// Возвращает: Строки агрегатов по ключу.
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByStringAsync(
IQueryable<TokenUsageEventEntity> source,
Expression<Func<TokenUsageEventEntity, string>> keySelector,
CancellationToken ct)
{
var rows = await source
.GroupBy(keySelector)
.Select(group => new
{
Key = group.Key,
Prompt = group.Sum(x => x.PromptTokens),
Completion = group.Sum(x => x.CompletionTokens),
Total = group.Sum(x => x.TotalTokens),
Count = group.LongCount(),
})
.ToListAsync(ct);
return rows
.OrderByDescending(row => row.Total)
.Select(row => new TokenUsageAggregateDto(row.Key, row.Prompt, row.Completion, row.Total, row.Count))
.ToList();
}
}
@@ -1,6 +1,9 @@
using System.Security.Cryptography;
using System.Text;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Infrastructure.Security;
@@ -1,267 +1,285 @@
using Deal.Contracts.Integrations;
using Deal.Infrastructure.Integrations;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Repositories;
using Deal.Infrastructure.Security;
using Deal.Infrastructure.Services;
using Deal.Infrastructure.Tenancy;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Discovery.Application;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Pipeline.Application;
using Deal.Modules.Settings.Application;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure;
/// <summary>
/// DI-регистрация адаптеров персистентности Deal.Infrastructure (паттерн «port &amp; adapter», Ruling 1).
/// </summary>
/// <remarks>
/// Регистрируются только EF-адаптеры портов модулей. Сервисы самих модулей регистрируют
/// модульные регистраторы (AddTenantsModule в Deal.Api). Адаптеры — scoped, потому что живут
/// на scoped-контекстах EF (DealDbContext), которыми владеет запрос.
/// </remarks>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Регистрирует EF-адаптеры портов модулей: IAuthStore, IOperatorAuthStore, ITenantRepository, IAuditLogStore, IInviteStore, ITenantLimitStore, ISettingsStore, IGlobalSettingsStore, ICardStore, IPipelineStore, IMlLearningStore, ITenantProvisioner.
/// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <param name="tenantLimitDefaults">Дефолт-бюджет лениво создаваемых строк tenant_limits (Ruling 3; null — константа TokenBudgetDefaults).
/// Передаётся из конфигурации/env DEAL_DEFAULT_AI_BUDGET в Program.cs (Task 8).</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
public static IServiceCollection AddDealPersistence(
this IServiceCollection services, TokenLimitDefaults? tenantLimitDefaults = null)
{
services.AddScoped<IAuthStore, AuthStore>();
// Хранилище аутентификации оператора (Ruling 1 этапа 7): таблицы operators/operator_sessions
// схемы public — отдельный порт от IAuthStore (оператор ≠ пользователь тенанта).
services.AddScoped<IOperatorAuthStore, OperatorAuthStore>();
services.AddScoped<ITenantRepository, TenantRepository>();
// Хранилище аудита (Ruling 4): таблица public.audit_log — append-only, порт без Update/Delete.
services.AddScoped<IAuditLogStore, AuditLogStore>();
// Хранилище истории расхода токенов (этап 10, T2): таблица public.token_usage_events — append-only
// + агрегаты для аналитики; порт без Update/Delete.
services.AddScoped<ITokenUsageEventStore, TokenUsageEventStore>();
// Хранилище приглашений (Ruling 2): таблица public.invites — создание/чтение/отзыв оператором (Task 5),
// статусные переходы меняет прикладной слой (InvitesService), активацию выполнит /api/join (Task 6).
services.AddScoped<IInviteStore, InviteStore>();
// Хранилище лимитов ИИ-бюджета (Ruling 3, Task 8): таблица public.tenant_limits. Фабрика передаёт
// дефолт-бюджет из конфигурации (Program.cs) — ленивый GetOrCreate новой строки использует его;
// DealDbContext уже зарегистрирован в Api (AddDbContext до AddDealPersistence).
services.AddScoped<ITenantLimitStore>(provider => new TenantLimitStore(
provider.GetRequiredService<DealDbContext>(),
tenantLimitDefaults ?? TokenBudgetDefaults.Default));
// Хранилище счётчиков фиксированного окна (этап 12, пакет B): таблица public.rate_limit_counters —
// общее хранилище распределённого rate limiting (auth/api/gRPC) и учёта попыток входа. Scoped
// (DealDbContext); лимитер резолвит его в собственном scope на каждое приобретение.
services.AddScoped<IRateLimitCounterStore, RateLimitCounterStore>();
// KV-хранилище настроек тенанта (Ruling 1): таблица settings в схеме тенанта,
// контекст — scoped TenantDbContext запроса (см. AddDbContext в Deal.Api).
services.AddScoped<ISettingsStore, SettingsStore>();
// KV-хранилище глобальных (системных) настроек оператора (ТЗ §4.1/§8.1): таблица
// public.global_settings, контекст — системный DealDbContext (public-схема). Ключи Telegram
// задаёт оператор, ядро читает их для команд входа (TelegramKeysService).
services.AddScoped<IGlobalSettingsStore, GlobalSettingsStore>();
// Хранилище карточек и контейнеров (Ruling 12): таблицы Cards/Containers/LeadComments/CardMoves в схеме тенанта.
services.AddScoped<ICardStore, KanbanStore>();
// Хранилище пайплайна (Ruling 10): таблицы QueueItems/RejectedItems/DedupEntries в схеме тенанта.
// KanbanStore при жёстком удалении карточки чистит DedupEntries напрямую тем же TenantDbContext
// (Ruling 3) — IPipelineStore для этого не привлекается, цикла Kanban → Pipeline нет.
services.AddScoped<IPipelineStore, PipelineStore>();
// Хранилище обучения ML (Ruling 4, Task 5): очередь MlOutbox + счётчик журнала CardMoves.
services.AddScoped<IMlLearningStore, MlLearningStore>();
// Хранилище каталога диалогов Telegram (Ruling 7, Task 13): таблицы Dialogs/TgMessages в схеме тенанта.
services.AddScoped<ITelegramStore, TelegramStore>();
// Хранилище Discovery (Ruling 9, Task 17): таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog в схеме
// тенанта. Проверки «уже мониторится» читают таблицу Dialogs (владелец — Telegram) тем же TenantDbContext.
services.AddScoped<IDiscoveryStore, DiscoveryStore>();
// Провижининг схем тенантов (Ruling 3). TenantProvisioningService зависит от
// ConnectionStringProvider — он регистрируется в Deal.Api (Program.cs) как singleton.
services.AddScoped<ITenantProvisioner, TenantProvisioningService>();
// Пакетная (maintenance) миграция схем всех тенантов (этап 12, пакет C): ограниченный
// параллелизм + логирование прогресса поверх ITenantRepository и ITenantProvisioner.
services.AddScoped<TenantSchemaMigrationService>();
// Единая точка перехода карточки между контейнерами (R4 этапа 9): выбор маршрута «стадия
// Выбранных vs дашборд-контейнер» живёт в адаптере, а не в эндпоинте. Scoped —
// композирует scoped-сервис карточек в рамках tenant-запроса.
services.AddScoped<ICardMover, CardMover>();
return services;
}
/// <summary>
/// Регистрирует адаптеры внешних интеграций (Rulings 4/5/6/9): IMlClient, IAiClassifier, IAiTools, IColumnSuggester, ITelegramGateway.
/// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <param name="mlOptions">Конфигурация секции <c>Services:Ml</c> — выбор реализации IMlClient (Ruling 6).</param>
/// <param name="aiOptions">Конфигурация секции <c>Services:Ai</c> — выбор реализации IAiClassifier/IAiTools (Ruling 6).</param>
/// <param name="telegramOptions">Конфигурация секции <c>Services:Telegram</c> — выбор реализации ITelegramGateway (Ruling 6).</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
/// <remarks>
/// IMlClient: при <c>UseLocal=true</c> (default) — Local-заглушка LocalMlClient (фолбэк этапов 2–5); при
/// <c>UseLocal=false</c> — gRPC-адаптер GrpcMlClient (ml.proto, Ruling 1/4) + синглтон-транспорт
/// MlGrpcConnection и кэш статуса MlStatusCache; тот же адаптер реализует IMlTrainClient для фонового
/// MlOutboxFlushScheduler (регистрируется в Deal.Api под тем же флагом). PushAsync в обоих режимах пишет
/// в MlOutbox (Ruling 6 — обучение всегда локально), выгрузку батчами делает флашер. IAiClassifier: при
/// <c>Services:Ai:UseLocal=true</c> — LocalAiClassifier (детерминированный разбор ядра, Ruling 5 этапа 4);
/// при false — GrpcAiClassifier (ai.proto; Ruling 5/6) + синглтон AiGrpcConnection (fail-fast, как
/// MlGrpcConnection) + scoped-сервисы конфига провайдера/учёта токенов. Бюджетный гейт (Task 9, Ruling 3):
/// в gRPC-режиме порты наружу отдаются декораторами BudgetedAiClassifier/BudgetedAiTools поверх gRPC-адаптеров
/// (порядок Grpc → Budgeted), Local-реализации регистрируются как бесплатный fallback гейта. IAiTools:
/// LocalAiTools (методы не поддерживаются — NotSupportedException, Ruling 9) либо GrpcAiTools за тем же флагом.
/// IColumnSuggester —
/// детерминированная Local-эвристика LocalColumnSuggester (Self-Review плана L525–527: на gRPC сознательно
/// не заменяется). ITelegramGateway: при <c>Services:Telegram:UseLocal=true</c> (default) — LocalTelegramGateway
/// (нейтральный no-op/idle, фолбэк до Task 14); при false — GrpcTelegramClient (telegram.proto, Ruling 7) +
/// синглтон-транспорт TelegramGrpcConnection (fail-fast).
/// Scoped: адаптеры читают KV-настройки тенанта (ISettingsStore → scoped TenantDbContext запроса) и
/// пишут в таблицы схемы тенанта, поэтому не могут жить дольше scope. HTTP-адаптеры других
/// интеграций (IAiConnectionChecker/IRatesSource) регистрируются в Deal.Api через AddHttpClient —
/// см. Program.cs (Tasks 6/8).
/// </remarks>
public static IServiceCollection AddDealIntegrations(
this IServiceCollection services, MlServiceOptions mlOptions, AiServiceOptions aiOptions,
TelegramServiceOptions telegramOptions, MtlsCertificates? mtlsCertificates = null)
{
// Сертификаты mTLS-каналов (Ruling 6, Task 13): null (флаг DEAL_MTLS_ENABLED выключен) — каналы
// остаются plaintext + service-token (dev); при включённом флаге каждый транспорт подписывает запрос
// клиентским сертификатом и проверяет CA сервера (fail-fast на загрузку — в MtlsCertificates.Load).
// Recorder расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): пишет бюджет/lifetime/историю
// событий. Регистрируется независимо от AI-режима — им пользуется и gRPC ML-клиент (событие kind=ml).
services.AddScoped<TokenUsageRecorder>();
// ML-клиент (Ruling 6, план Task 16): выбор на старте — Local-заглушка либо gRPC-адаптер ml-service.
if (mlOptions.UseLocal)
{
// Обучение копится в MlOutbox (этап 3); отправка в ML-сервис не выполняется (сервиса нет в dev) —
// очередь остаётся накопленной, как в прототипе при недоступном сервисе (python L56–82).
services.AddScoped<IMlClient, LocalMlClient>();
}
else
{
// gRPC-клиент ml-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают
// старт — Ruling 2/13), кэш статуса 15 с переживает scope запросов. Scoped GrpcMlClient читает
// KV/таблицы тенанта (как LocalMlClient) и ходит в сервис по metadata tenant-id/service-token.
services.AddSingleton(new MlGrpcConnection(mlOptions, mtlsCertificates));
services.AddSingleton<MlStatusCache>();
services.AddScoped<GrpcMlClient>();
services.AddScoped<IMlClient>(provider => provider.GetRequiredService<GrpcMlClient>());
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
}
// ИИ-предложения колонок/ключей (Ruling 3, Task 14): порт Contracts → детерминированная эвристика.
// LocalColumnSuggester читает карточки через ICardStore, считает группы ядром SuggestHeuristics
// (модуль Kanban) и создаёт доски suggested=true через ContainersService; scoped — его зависимости
// живут в рамках tenant-запроса (KanbanStore/TenantDbContext). На этапе 6 адаптер заменяется
// gRPC-клиентом ai-service с тем же контрактом.
services.AddScoped<IColumnSuggester, LocalColumnSuggester>();
// ИИ-классификатор входящих (Ruling 5/6, Task 15): порт Contracts → детерминированная Local-реализация
// (UseLocal=true) либо gRPC-адаптер ai-service (UseLocal=false). LocalAiClassifier разбирает сообщение
// ядром LocalFieldsParser модуля Pipeline (маркерная гипотеза типа: is_vacancy_known=false, board=null —
// «смысловые колонки до ИИ не назначаем») и всегда пропускает ИИ-фильтр {pass:true, skipped:true};
// scoped — LocalFieldsParser читает маркеры hireMarkers/levelTerms из KV-настроек тенанта (ISettingsStore).
// GrpcAiClassifier строит промпты/контекст классификации (AiClassifyContextBuilder модуля Pipeline,
// регистрируется AddPipelineModule), ходит в ai-service с ProviderConfig из настроек (расшифровка apiKey)
// и копит usage в KV aiTokenUsage; недоступность/ok=false → AiUnavailableException — воркер падает в
// локальный разбор (aiFail, как raw={} python L1108–1114). Выбор на старте, рантайм-логики нет (Ruling 6).
// Списывание в tenant_limits + lifetime-KV aiTokenUsage выполняет TokenUsageRecorder (Ruling 3, Task 8).
if (aiOptions.UseLocal)
{
services.AddScoped<IAiClassifier, LocalAiClassifier>();
services.AddScoped<IAiTools, LocalAiTools>();
}
else
{
// gRPC-клиент ai-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают
// старт — Ruling 2/13), как MlGrpcConnection. Scoped-адаптеры читают настройки/данные тенанта и ходят
// в сервис по metadata tenant-id/service-token; usage ответов списывает TokenUsageRecorder
// (tenant_limits + lifetime-KV aiTokenUsage, Ruling 3 этапа 7).
services.AddSingleton(new AiGrpcConnection(aiOptions, mtlsCertificates));
services.AddScoped<AiProviderConfigBuilder>();
services.AddScoped<GrpcAiClassifier>();
// Локальные адаптеры как fallback бюджетного гейта (Ruling 3, Task 9): даже в gRPC-режиме декоратор при
// исчерпанном бюджете/приостановке уводит вызов на детерминированный бесплатный локальный разбор
// (LocalAiClassifier; LocalFieldsParser регистрирует AddPipelineModule в Program.cs). Инструменты локального
// fallback не имеют — при запрете BudgetedAiTools бросает AiUnavailableException/мягкую ошибку (Ruling 3).
services.AddScoped<LocalAiClassifier>();
// Декораторы бюджетного гейта (порядок Grpc → Budgeted → наружу): перед каждым платным вызовом
// GetStateAsync (ITenantLimitStore, scoped) — исчерпано/приостановлено → Local-классификатор либо
// исключение/мягкая ошибка инструментов; списание usage остаётся внутри gRPC-адаптеров (Task 8).
services.AddScoped<IAiClassifier>(provider => new BudgetedAiClassifier(
provider.GetRequiredService<GrpcAiClassifier>(),
provider.GetRequiredService<LocalAiClassifier>(),
provider.GetRequiredService<ITenantLimitStore>(),
provider.GetRequiredService<ITenantContext>(),
provider.GetRequiredService<ILogger<BudgetedAiClassifier>>()));
services.AddScoped<GrpcAiTools>();
services.AddScoped<IAiTools>(provider => new BudgetedAiTools(
provider.GetRequiredService<GrpcAiTools>(),
provider.GetRequiredService<ITenantLimitStore>(),
provider.GetRequiredService<ITenantContext>(),
provider.GetRequiredService<ILogger<BudgetedAiTools>>()));
}
// Гейт telegram-service (Ruling 6/7, план Task 14): порт Contracts. По умолчанию (UseLocal=true) —
// локальная заглушка dev LocalTelegramGateway (нейтральный no-op/idle — сервис не поднят); при
// UseLocal=false — gRPC-клиент GrpcTelegramClient (telegram.proto) + синглтон-транспорт
// TelegramGrpcConnection (fail-fast, как MlGrpcConnection). Scoped-адаптер: tenant-id для metadata
// берёт из ITenantContext (AsyncLocal) scope вызова (HTTP-запрос/tenant-циклы ингресса).
if (telegramOptions.UseLocal)
{
services.AddSingleton<ITelegramGateway, LocalTelegramGateway>();
}
else
{
services.AddSingleton(new TelegramGrpcConnection(telegramOptions, mtlsCertificates));
services.AddScoped<GrpcTelegramClient>();
services.AddScoped<ITelegramGateway>(provider => provider.GetRequiredService<GrpcTelegramClient>());
}
return services;
}
/// <summary>
/// Регистрирует сервисы шифрования секретов: ISecretCipher → AesGcmSecretCipher (Ruling 2).
/// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <param name="contentRootPath">ContentRoot приложения — каталог по умолчанию для файла-ключа data/encryption.key.</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
/// <remarks>
/// Ключ разрешается один раз при вызове: невалидный DEAL_ENCRYPTION_KEY → исключение при старте
/// (план Task 1). EncryptionKeyProvider в контейнер не регистрируется — после разрешения ключа
/// он рантайм-сервисам не нужен. ISecretCipher — singleton: реализация без разделяемого
/// состояния (AesGcm создаётся на операцию), поэтому потокобезопасна.
/// </remarks>
public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath)
{
EncryptionKeyProvider keyProvider = new(contentRootPath);
byte[] key = keyProvider.GetKey();
services.AddSingleton<ISecretCipher>(new AesGcmSecretCipher(key));
return services;
}
}
using Deal.Contracts.Integrations;
using Deal.Infrastructure.Integrations;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Repositories;
using Deal.Infrastructure.Security;
using Deal.Infrastructure.Services;
using Deal.Infrastructure.Tenancy;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.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.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Pipeline.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.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure;
/// <summary>
/// DI-регистрация адаптеров персистентности Deal.Infrastructure (паттерн «port &amp; adapter», Ruling 1).
/// </summary>
/// <remarks>
/// Регистрируются только EF-адаптеры портов модулей. Сервисы самих модулей регистрируют
/// модульные регистраторы (AddTenantsModule в Deal.Api). Адаптеры — scoped, потому что живут
/// на scoped-контекстах EF (DealDbContext), которыми владеет запрос.
/// </remarks>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Регистрирует EF-адаптеры портов модулей: IAuthStore, IOperatorAuthStore, ITenantRepository, IAuditLogStore, IInviteStore, ITenantLimitStore, ISettingsStore, IGlobalSettingsStore, ICardStore, IPipelineStore, IMlLearningStore, ITenantProvisioner.
/// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <param name="tenantLimitDefaults">Дефолт-бюджет лениво создаваемых строк tenant_limits (Ruling 3; null — константа TokenBudgetDefaults).
/// Передаётся из конфигурации/env DEAL_DEFAULT_AI_BUDGET в Program.cs (Task 8).</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
public static IServiceCollection AddDealPersistence(
this IServiceCollection services, TokenLimitDefaults? tenantLimitDefaults = null)
{
services.AddScoped<IAuthStore, AuthStore>();
// Хранилище аутентификации оператора (Ruling 1 этапа 7): таблицы operators/operator_sessions
// схемы public — отдельный порт от IAuthStore (оператор ≠ пользователь тенанта).
services.AddScoped<IOperatorAuthStore, OperatorAuthStore>();
services.AddScoped<ITenantRepository, TenantRepository>();
// Хранилище аудита (Ruling 4): таблица public.audit_log — append-only, порт без Update/Delete.
services.AddScoped<IAuditLogStore, AuditLogStore>();
// Хранилище истории расхода токенов (этап 10, T2): таблица public.token_usage_events — append-only
// + агрегаты для аналитики; порт без Update/Delete.
services.AddScoped<ITokenUsageEventStore, TokenUsageEventStore>();
// Хранилище приглашений (Ruling 2): таблица public.invites — создание/чтение/отзыв оператором (Task 5),
// статусные переходы меняет прикладной слой (InvitesService), активацию выполнит /api/join (Task 6).
services.AddScoped<IInviteStore, InviteStore>();
// Хранилище лимитов ИИ-бюджета (Ruling 3, Task 8): таблица public.tenant_limits. Фабрика передаёт
// дефолт-бюджет из конфигурации (Program.cs) — ленивый GetOrCreate новой строки использует его;
// DealDbContext уже зарегистрирован в Api (AddDbContext до AddDealPersistence).
services.AddScoped<ITenantLimitStore>(provider => new TenantLimitStore(
provider.GetRequiredService<DealDbContext>(),
tenantLimitDefaults ?? TokenBudgetDefaults.Default));
// Хранилище счётчиков фиксированного окна (этап 12, пакет B): таблица public.rate_limit_counters —
// общее хранилище распределённого rate limiting (auth/api/gRPC) и учёта попыток входа. Scoped
// (DealDbContext); лимитер резолвит его в собственном scope на каждое приобретение.
services.AddScoped<IRateLimitCounterStore, RateLimitCounterStore>();
// KV-хранилище настроек тенанта (Ruling 1): таблица settings в схеме тенанта,
// контекст — scoped TenantDbContext запроса (см. AddDbContext в Deal.Api).
services.AddScoped<ISettingsStore, SettingsStore>();
// KV-хранилище глобальных (системных) настроек оператора (ТЗ §4.1/§8.1): таблица
// public.global_settings, контекст — системный DealDbContext (public-схема). Ключи Telegram
// задаёт оператор, ядро читает их для команд входа (TelegramKeysService).
services.AddScoped<IGlobalSettingsStore, GlobalSettingsStore>();
// Хранилище карточек и контейнеров (Ruling 12): таблицы Cards/Containers/LeadComments/CardMoves в схеме тенанта.
services.AddScoped<ICardStore, KanbanStore>();
// Хранилище пайплайна (Ruling 10): таблицы QueueItems/RejectedItems/DedupEntries в схеме тенанта.
// KanbanStore при жёстком удалении карточки чистит DedupEntries напрямую тем же TenantDbContext
// (Ruling 3) — IPipelineStore для этого не привлекается, цикла Kanban → Pipeline нет.
services.AddScoped<IPipelineStore, PipelineStore>();
// Хранилище обучения ML (Ruling 4, Task 5): очередь MlOutbox + счётчик журнала CardMoves.
services.AddScoped<IMlLearningStore, MlLearningStore>();
// Хранилище каталога диалогов Telegram (Ruling 7, Task 13): таблицы Dialogs/TgMessages в схеме тенанта.
services.AddScoped<ITelegramStore, TelegramStore>();
// Хранилище Discovery (Ruling 9, Task 17): таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog в схеме
// тенанта. Проверки «уже мониторится» читают таблицу Dialogs (владелец — Telegram) тем же TenantDbContext.
services.AddScoped<IDiscoveryStore, DiscoveryStore>();
// Провижининг схем тенантов (Ruling 3). TenantProvisioningService зависит от
// ConnectionStringProvider — он регистрируется в Deal.Api (Program.cs) как singleton.
services.AddScoped<ITenantProvisioner, TenantProvisioningService>();
// Пакетная (maintenance) миграция схем всех тенантов (этап 12, пакет C): ограниченный
// параллелизм + логирование прогресса поверх ITenantRepository и ITenantProvisioner.
services.AddScoped<TenantSchemaMigrationService>();
// Единая точка перехода карточки между контейнерами (R4 этапа 9): выбор маршрута «стадия
// Выбранных vs дашборд-контейнер» живёт в адаптере, а не в эндпоинте. Scoped —
// композирует scoped-сервис карточек в рамках tenant-запроса.
services.AddScoped<ICardMover, CardMover>();
return services;
}
/// <summary>
/// Регистрирует адаптеры внешних интеграций (Rulings 4/5/6/9): IMlClient, IAiClassifier, IAiTools, IColumnSuggester, ITelegramGateway.
/// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <param name="mlOptions">Конфигурация секции <c>Services:Ml</c> — выбор реализации IMlClient (Ruling 6).</param>
/// <param name="aiOptions">Конфигурация секции <c>Services:Ai</c> — выбор реализации IAiClassifier/IAiTools (Ruling 6).</param>
/// <param name="telegramOptions">Конфигурация секции <c>Services:Telegram</c> — выбор реализации ITelegramGateway (Ruling 6).</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
/// <remarks>
/// IMlClient: при <c>UseLocal=true</c> (default) — Local-заглушка LocalMlClient (фолбэк этапов 2–5); при
/// <c>UseLocal=false</c> — gRPC-адаптер GrpcMlClient (ml.proto, Ruling 1/4) + синглтон-транспорт
/// MlGrpcConnection и кэш статуса MlStatusCache; тот же адаптер реализует IMlTrainClient для фонового
/// MlOutboxFlushScheduler (регистрируется в Deal.Api под тем же флагом). PushAsync в обоих режимах пишет
/// в MlOutbox (Ruling 6 — обучение всегда локально), выгрузку батчами делает флашер. IAiClassifier: при
/// <c>Services:Ai:UseLocal=true</c> — LocalAiClassifier (детерминированный разбор ядра, Ruling 5 этапа 4);
/// при false — GrpcAiClassifier (ai.proto; Ruling 5/6) + синглтон AiGrpcConnection (fail-fast, как
/// MlGrpcConnection) + scoped-сервисы конфига провайдера/учёта токенов. Бюджетный гейт (Task 9, Ruling 3):
/// в gRPC-режиме порты наружу отдаются декораторами BudgetedAiClassifier/BudgetedAiTools поверх gRPC-адаптеров
/// (порядок Grpc → Budgeted), Local-реализации регистрируются как бесплатный fallback гейта. IAiTools:
/// LocalAiTools (методы не поддерживаются — NotSupportedException, Ruling 9) либо GrpcAiTools за тем же флагом.
/// IColumnSuggester —
/// детерминированная Local-эвристика LocalColumnSuggester (Self-Review плана L525–527: на gRPC сознательно
/// не заменяется). ITelegramGateway: при <c>Services:Telegram:UseLocal=true</c> (default) — LocalTelegramGateway
/// (нейтральный no-op/idle, фолбэк до Task 14); при false — GrpcTelegramClient (telegram.proto, Ruling 7) +
/// синглтон-транспорт TelegramGrpcConnection (fail-fast).
/// Scoped: адаптеры читают KV-настройки тенанта (ISettingsStore → scoped TenantDbContext запроса) и
/// пишут в таблицы схемы тенанта, поэтому не могут жить дольше scope. HTTP-адаптеры других
/// интеграций (IAiConnectionChecker/IRatesSource) регистрируются в Deal.Api через AddHttpClient —
/// см. Program.cs (Tasks 6/8).
/// </remarks>
public static IServiceCollection AddDealIntegrations(
this IServiceCollection services, MlServiceOptions mlOptions, AiServiceOptions aiOptions,
TelegramServiceOptions telegramOptions, MtlsCertificates? mtlsCertificates = null)
{
// Сертификаты mTLS-каналов (Ruling 6, Task 13): null (флаг DEAL_MTLS_ENABLED выключен) — каналы
// остаются plaintext + service-token (dev); при включённом флаге каждый транспорт подписывает запрос
// клиентским сертификатом и проверяет CA сервера (fail-fast на загрузку — в MtlsCertificates.Load).
// Recorder расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): пишет бюджет/lifetime/историю
// событий. Регистрируется независимо от AI-режима — им пользуется и gRPC ML-клиент (событие kind=ml).
services.AddScoped<TokenUsageRecorder>();
// ML-клиент (Ruling 6, план Task 16): выбор на старте — Local-заглушка либо gRPC-адаптер ml-service.
if (mlOptions.UseLocal)
{
// Обучение копится в MlOutbox (этап 3); отправка в ML-сервис не выполняется (сервиса нет в dev) —
// очередь остаётся накопленной, как в прототипе при недоступном сервисе (python L56–82).
services.AddScoped<IMlClient, LocalMlClient>();
}
else
{
// gRPC-клиент ml-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают
// старт — Ruling 2/13), кэш статуса 15 с переживает scope запросов. Scoped GrpcMlClient читает
// KV/таблицы тенанта (как LocalMlClient) и ходит в сервис по metadata tenant-id/service-token.
services.AddSingleton(new MlGrpcConnection(mlOptions, mtlsCertificates));
services.AddSingleton<MlStatusCache>();
services.AddScoped<GrpcMlClient>();
services.AddScoped<IMlClient>(provider => provider.GetRequiredService<GrpcMlClient>());
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
}
// ИИ-предложения колонок/ключей (Ruling 3, Task 14): порт Contracts → детерминированная эвристика.
// LocalColumnSuggester читает карточки через ICardStore, считает группы ядром SuggestHeuristics
// (модуль Kanban) и создаёт доски suggested=true через ContainersService; scoped — его зависимости
// живут в рамках tenant-запроса (KanbanStore/TenantDbContext). На этапе 6 адаптер заменяется
// gRPC-клиентом ai-service с тем же контрактом.
services.AddScoped<IColumnSuggester, LocalColumnSuggester>();
// ИИ-классификатор входящих (Ruling 5/6, Task 15): порт Contracts → детерминированная Local-реализация
// (UseLocal=true) либо gRPC-адаптер ai-service (UseLocal=false). LocalAiClassifier разбирает сообщение
// ядром LocalFieldsParser модуля Pipeline (маркерная гипотеза типа: is_vacancy_known=false, board=null —
// «смысловые колонки до ИИ не назначаем») и всегда пропускает ИИ-фильтр {pass:true, skipped:true};
// scoped — LocalFieldsParser читает маркеры hireMarkers/levelTerms из KV-настроек тенанта (ISettingsStore).
// GrpcAiClassifier строит промпты/контекст классификации (AiClassifyContextBuilder модуля Pipeline,
// регистрируется AddPipelineModule), ходит в ai-service с ProviderConfig из настроек (расшифровка apiKey)
// и копит usage в KV aiTokenUsage; недоступность/ok=false → AiUnavailableException — воркер падает в
// локальный разбор (aiFail, как raw={} python L1108–1114). Выбор на старте, рантайм-логики нет (Ruling 6).
// Списывание в tenant_limits + lifetime-KV aiTokenUsage выполняет TokenUsageRecorder (Ruling 3, Task 8).
if (aiOptions.UseLocal)
{
services.AddScoped<IAiClassifier, LocalAiClassifier>();
services.AddScoped<IAiTools, LocalAiTools>();
}
else
{
// gRPC-клиент ai-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают
// старт — Ruling 2/13), как MlGrpcConnection. Scoped-адаптеры читают настройки/данные тенанта и ходят
// в сервис по metadata tenant-id/service-token; usage ответов списывает TokenUsageRecorder
// (tenant_limits + lifetime-KV aiTokenUsage, Ruling 3 этапа 7).
services.AddSingleton(new AiGrpcConnection(aiOptions, mtlsCertificates));
services.AddScoped<AiProviderConfigBuilder>();
services.AddScoped<GrpcAiClassifier>();
// Локальные адаптеры как fallback бюджетного гейта (Ruling 3, Task 9): даже в gRPC-режиме декоратор при
// исчерпанном бюджете/приостановке уводит вызов на детерминированный бесплатный локальный разбор
// (LocalAiClassifier; LocalFieldsParser регистрирует AddPipelineModule в Program.cs). Инструменты локального
// fallback не имеют — при запрете BudgetedAiTools бросает AiUnavailableException/мягкую ошибку (Ruling 3).
services.AddScoped<LocalAiClassifier>();
// Декораторы бюджетного гейта (порядок Grpc → Budgeted → наружу): перед каждым платным вызовом
// GetStateAsync (ITenantLimitStore, scoped) — исчерпано/приостановлено → Local-классификатор либо
// исключение/мягкая ошибка инструментов; списание usage остаётся внутри gRPC-адаптеров (Task 8).
services.AddScoped<IAiClassifier>(provider => new BudgetedAiClassifier(
provider.GetRequiredService<GrpcAiClassifier>(),
provider.GetRequiredService<LocalAiClassifier>(),
provider.GetRequiredService<ITenantLimitStore>(),
provider.GetRequiredService<ITenantContext>(),
provider.GetRequiredService<ILogger<BudgetedAiClassifier>>()));
services.AddScoped<GrpcAiTools>();
services.AddScoped<IAiTools>(provider => new BudgetedAiTools(
provider.GetRequiredService<GrpcAiTools>(),
provider.GetRequiredService<ITenantLimitStore>(),
provider.GetRequiredService<ITenantContext>(),
provider.GetRequiredService<ILogger<BudgetedAiTools>>()));
}
// Гейт telegram-service (Ruling 6/7, план Task 14): порт Contracts. По умолчанию (UseLocal=true) —
// локальная заглушка dev LocalTelegramGateway (нейтральный no-op/idle — сервис не поднят); при
// UseLocal=false — gRPC-клиент GrpcTelegramClient (telegram.proto) + синглтон-транспорт
// TelegramGrpcConnection (fail-fast, как MlGrpcConnection). Scoped-адаптер: tenant-id для metadata
// берёт из ITenantContext (AsyncLocal) scope вызова (HTTP-запрос/tenant-циклы ингресса).
if (telegramOptions.UseLocal)
{
services.AddSingleton<ITelegramGateway, LocalTelegramGateway>();
}
else
{
services.AddSingleton(new TelegramGrpcConnection(telegramOptions, mtlsCertificates));
services.AddScoped<GrpcTelegramClient>();
services.AddScoped<ITelegramGateway>(provider => provider.GetRequiredService<GrpcTelegramClient>());
}
return services;
}
/// <summary>
/// Регистрирует сервисы шифрования секретов: ISecretCipher → AesGcmSecretCipher (Ruling 2).
/// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <param name="contentRootPath">ContentRoot приложения — каталог по умолчанию для файла-ключа data/encryption.key.</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
/// <remarks>
/// Ключ разрешается один раз при вызове: невалидный DEAL_ENCRYPTION_KEY → исключение при старте
/// (план Task 1). EncryptionKeyProvider в контейнер не регистрируется — после разрешения ключа
/// он рантайм-сервисам не нужен. ISecretCipher — singleton: реализация без разделяемого
/// состояния (AesGcm создаётся на операцию), поэтому потокобезопасна.
/// </remarks>
public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath)
{
EncryptionKeyProvider keyProvider = new(contentRootPath);
byte[] key = keyProvider.GetKey();
services.AddSingleton<ISecretCipher>(new AesGcmSecretCipher(key));
return services;
}
}
@@ -1,38 +1,41 @@
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Infrastructure.Services;
/// <summary>
/// Единая точка перехода карточки между контейнерами — реализация порта <see cref="ICardMover"/>
/// (R4 этапа 9).
/// </summary>
/// <remarks>
/// Маршрутизация цели живёт в домене: цель-стадия пространства «Выбранные»
/// (<see cref="CardsDefaultContainers"/>) переходит через <see cref="CardsService.MoveStageCardAsync"/> —
/// запись истории движения и сброс напоминания; остальные цели (доски/служебные зоны) — штатный перенос
/// дашборда <see cref="CardsService.MoveDashboardCardAsync"/> (журнал CardMoves, matchHits, обучение ML).
/// Адаптер только нормализует результаты обоих маршрутов к <see cref="CardMoveResultDto"/>; снимок карточки
/// эндпоинт перечитывает единым чтением. Scoped: зависимости живут в рамках tenant-запроса.
/// </remarks>
/// <param name="cardsService">Сервис карточек — единый домен перехода (стадии и контейнеры дашборда).</param>
public sealed class CardMover(CardsService cardsService) : ICardMover
{
/// <inheritdoc />
public async Task<CardMoveResultDto> MoveAsync(
string cardId, string toContainerId, TransitionContext ctx, CancellationToken ct)
{
// Контекст перехода (инициатор/обучение) учтён внутри маршрутов: дашборд-перенос обучает ML по
// цели пользователя, переход по стадии пишет историю и сбрасывает напоминание. Отдельного
// ветвления по ctx сейчас нет — оно появится с политиками контейнеров (R4).
CardResultDto result = CardsDefaultContainers.Contains(toContainerId)
? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct)
: await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct);
return result.Error is not null
? new CardMoveResultDto(result.Error, Exists: true)
: new CardMoveResultDto(null, Exists: result.Card is not null);
}
}
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
namespace Deal.Infrastructure.Services;
/// <summary>
/// Единая точка перехода карточки между контейнерами — реализация порта <see cref="ICardMover"/>
/// (R4 этапа 9).
/// </summary>
/// <remarks>
/// Маршрутизация цели живёт в домене: цель-стадия пространства «Выбранные»
/// (<see cref="CardsDefaultContainers"/>) переходит через <see cref="CardsService.MoveStageCardAsync"/> —
/// запись истории движения и сброс напоминания; остальные цели (доски/служебные зоны) — штатный перенос
/// дашборда <see cref="CardsService.MoveDashboardCardAsync"/> (журнал CardMoves, matchHits, обучение ML).
/// Адаптер только нормализует результаты обоих маршрутов к <see cref="CardMoveResultDto"/>; снимок карточки
/// эндпоинт перечитывает единым чтением. Scoped: зависимости живут в рамках tenant-запроса.
/// </remarks>
/// <param name="cardsService">Сервис карточек — единый домен перехода (стадии и контейнеры дашборда).</param>
public sealed class CardMover(CardsService cardsService) : ICardMover
{
/// <inheritdoc />
public async Task<CardMoveResultDto> MoveAsync(
string cardId, string toContainerId, TransitionContext ctx, CancellationToken ct)
{
// Контекст перехода (инициатор/обучение) учтён внутри маршрутов: дашборд-перенос обучает ML по
// цели пользователя, переход по стадии пишет историю и сбрасывает напоминание. Отдельного
// ветвления по ctx сейчас нет — оно появится с политиками контейнеров (R4).
CardResultDto result = CardsDefaultContainers.Contains(toContainerId)
? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct)
: await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct);
return result.Error is not null
? new CardMoveResultDto(result.Error, Exists: true)
: new CardMoveResultDto(null, Exists: result.Card is not null);
}
}
@@ -4,8 +4,11 @@ using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Tenancy;
@@ -2,7 +2,11 @@ using System.Collections.Concurrent;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Migrations;
using Deal.Infrastructure.Persistence;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants;
using Microsoft.EntityFrameworkCore;
using Npgsql;
@@ -1,110 +1,113 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Tenancy;
/// <summary>
/// Пакетная (maintenance) миграция схем ВСЕХ существующих тенантов реестра (этап 12, пакет C) с
/// ограниченным параллелизмом и логированием прогресса — для провижининга SaaS на сотни/тысячи схем.
/// </summary>
/// <remarks>
/// Идемпотентность обеспечивает сам провижининг: <c>CREATE SCHEMA IF NOT EXISTS</c> + EF Core
/// <c>MigrateAsync</c>, который сверяется с таблицей истории <c>__TenantMigrationsHistory</c> схемы тенанта
/// и применяет ТОЛЬКО неприменённые миграции (повторный прогон ничего не меняет). Поэтому пакетный прогон
/// безопасен и при повторном запуске, и параллельно со стартовым bootstrap. Сбой одной схемы не прерывает
/// остальные: ошибка логируется, схема попадает в <see cref="TenantMigrationSummary.FailedSchemas"/>, а
/// операция завершается итоговой сводкой. Степень параллелизма клампится разумными границами, чтобы не
/// перегрузить Postgres при большом реестре.
/// </remarks>
public sealed class TenantSchemaMigrationService(
ITenantRepository tenantRepository,
ITenantProvisioner tenantProvisioner,
ILogger<TenantSchemaMigrationService> logger)
{
/// <summary>
/// Параллелизм пакетной миграции по умолчанию (схем одновременно).
/// </summary>
public const int DefaultMaxParallelism = 4;
// Нижняя граница параллелизма (ноль/отрицательное значение недопустимо).
private const int MinMaxParallelism = 1;
// Верхняя граница параллелизма: защита Postgres от лавины одновременных DDL-подключений.
private const int MaxMaxParallelism = 32;
/// <summary>
/// Мигрирует схемы всех тенантов реестра с параллелизмом по умолчанию.
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Итоговая сводка пакетной миграции.</returns>
public Task<TenantMigrationSummary> MigrateAllAsync(CancellationToken ct)
=> MigrateAllAsync(DefaultMaxParallelism, ct);
/// <summary>
/// Мигрирует схемы всех тенантов реестра с заданным параллелизмом (значение клампится).
/// </summary>
/// <param name="maxParallelism">Желаемое число схем, мигрируемых одновременно.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Итоговая сводка пакетной миграции.</returns>
public async Task<TenantMigrationSummary> MigrateAllAsync(int maxParallelism, CancellationToken ct)
{
int parallelism = Math.Clamp(maxParallelism, MinMaxParallelism, MaxMaxParallelism);
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct).ConfigureAwait(false);
var stopwatch = Stopwatch.StartNew();
if (tenants.Count == 0)
{
stopwatch.Stop();
logger.LogInformation("Пакетная миграция схем: в реестре нет тенантов — мигрировать нечего");
return new TenantMigrationSummary(0, 0, 0, stopwatch.ElapsedMilliseconds, []);
}
var failedSchemas = new ConcurrentBag<string>();
int migrated = 0;
await Parallel.ForEachAsync(
tenants,
new ParallelOptions { MaxDegreeOfParallelism = parallelism, CancellationToken = ct },
async (tenant, token) =>
{
var tenantId = new TenantId(tenant.Id.ToString("N"));
try
{
await tenantProvisioner.ProvisionAsync(tenantId, token).ConfigureAwait(false);
int done = Interlocked.Increment(ref migrated);
logger.LogInformation(
"Пакетная миграция схем: {Done}/{Total} — {Schema} готова",
done,
tenants.Count,
tenantId.SchemaName);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
failedSchemas.Add(tenantId.SchemaName);
logger.LogError(
exception,
"Пакетная миграция схем: {Schema} не мигрирована",
tenantId.SchemaName);
}
}).ConfigureAwait(false);
stopwatch.Stop();
var summary = new TenantMigrationSummary(
tenants.Count,
migrated,
failedSchemas.Count,
stopwatch.ElapsedMilliseconds,
failedSchemas.OrderBy(schema => schema, StringComparer.Ordinal).ToArray());
logger.LogInformation(
"Пакетная миграция схем завершена: всего {Total}, успешно {Migrated}, сбоев {Failed}, {DurationMs} мс",
summary.Total,
summary.Migrated,
summary.Failed,
summary.DurationMs);
return summary;
}
}
using System.Collections.Concurrent;
using System.Diagnostics;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Tenancy;
/// <summary>
/// Пакетная (maintenance) миграция схем ВСЕХ существующих тенантов реестра (этап 12, пакет C) с
/// ограниченным параллелизмом и логированием прогресса — для провижининга SaaS на сотни/тысячи схем.
/// </summary>
/// <remarks>
/// Идемпотентность обеспечивает сам провижининг: <c>CREATE SCHEMA IF NOT EXISTS</c> + EF Core
/// <c>MigrateAsync</c>, который сверяется с таблицей истории <c>__TenantMigrationsHistory</c> схемы тенанта
/// и применяет ТОЛЬКО неприменённые миграции (повторный прогон ничего не меняет). Поэтому пакетный прогон
/// безопасен и при повторном запуске, и параллельно со стартовым bootstrap. Сбой одной схемы не прерывает
/// остальные: ошибка логируется, схема попадает в <see cref="TenantMigrationSummary.FailedSchemas"/>, а
/// операция завершается итоговой сводкой. Степень параллелизма клампится разумными границами, чтобы не
/// перегрузить Postgres при большом реестре.
/// </remarks>
public sealed class TenantSchemaMigrationService(
ITenantRepository tenantRepository,
ITenantProvisioner tenantProvisioner,
ILogger<TenantSchemaMigrationService> logger)
{
/// <summary>
/// Параллелизм пакетной миграции по умолчанию (схем одновременно).
/// </summary>
public const int DefaultMaxParallelism = 4;
// Нижняя граница параллелизма (ноль/отрицательное значение недопустимо).
private const int MinMaxParallelism = 1;
// Верхняя граница параллелизма: защита Postgres от лавины одновременных DDL-подключений.
private const int MaxMaxParallelism = 32;
/// <summary>
/// Мигрирует схемы всех тенантов реестра с параллелизмом по умолчанию.
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Итоговая сводка пакетной миграции.</returns>
public Task<TenantMigrationSummary> MigrateAllAsync(CancellationToken ct)
=> MigrateAllAsync(DefaultMaxParallelism, ct);
/// <summary>
/// Мигрирует схемы всех тенантов реестра с заданным параллелизмом (значение клампится).
/// </summary>
/// <param name="maxParallelism">Желаемое число схем, мигрируемых одновременно.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Итоговая сводка пакетной миграции.</returns>
public async Task<TenantMigrationSummary> MigrateAllAsync(int maxParallelism, CancellationToken ct)
{
int parallelism = Math.Clamp(maxParallelism, MinMaxParallelism, MaxMaxParallelism);
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct).ConfigureAwait(false);
var stopwatch = Stopwatch.StartNew();
if (tenants.Count == 0)
{
stopwatch.Stop();
logger.LogInformation("Пакетная миграция схем: в реестре нет тенантов — мигрировать нечего");
return new TenantMigrationSummary(0, 0, 0, stopwatch.ElapsedMilliseconds, []);
}
var failedSchemas = new ConcurrentBag<string>();
int migrated = 0;
await Parallel.ForEachAsync(
tenants,
new ParallelOptions { MaxDegreeOfParallelism = parallelism, CancellationToken = ct },
async (tenant, token) =>
{
var tenantId = new TenantId(tenant.Id.ToString("N"));
try
{
await tenantProvisioner.ProvisionAsync(tenantId, token).ConfigureAwait(false);
int done = Interlocked.Increment(ref migrated);
logger.LogInformation(
"Пакетная миграция схем: {Done}/{Total} — {Schema} готова",
done,
tenants.Count,
tenantId.SchemaName);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
failedSchemas.Add(tenantId.SchemaName);
logger.LogError(
exception,
"Пакетная миграция схем: {Schema} не мигрирована",
tenantId.SchemaName);
}
}).ConfigureAwait(false);
stopwatch.Stop();
var summary = new TenantMigrationSummary(
tenants.Count,
migrated,
failedSchemas.Count,
stopwatch.ElapsedMilliseconds,
failedSchemas.OrderBy(schema => schema, StringComparer.Ordinal).ToArray());
logger.LogInformation(
"Пакетная миграция схем завершена: всего {Total}, успешно {Migrated}, сбоев {Failed}, {DurationMs} мс",
summary.Total,
summary.Migrated,
summary.Failed,
summary.DurationMs);
return summary;
}
}