Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация записи аудита: таблица audit_log в схеме public (append-only).
|
||||
/// </summary>
|
||||
public sealed class AuditLogConfiguration : IEntityTypeConfiguration<AuditLogEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AuditLogEntity> builder)
|
||||
{
|
||||
builder.ToTable("audit_log", "public");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.At).IsRequired();
|
||||
builder.Property(x => x.ActorType).IsRequired();
|
||||
builder.Property(x => x.EventType).IsRequired();
|
||||
builder.Property(x => x.DetailJson).HasColumnType("text");
|
||||
|
||||
builder.HasIndex(x => x.At);
|
||||
builder.HasIndex(x => new { x.TenantId, x.EventType });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация карточки канбана: таблица Cards (модель без схемы).
|
||||
/// </summary>
|
||||
public sealed class CardConfiguration : IEntityTypeConfiguration<CardEntity>
|
||||
{
|
||||
// Максимальная длина Col: служебные значения + id доски (короткие строки b_...).
|
||||
private const int ColMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<CardEntity> builder)
|
||||
{
|
||||
builder.ToTable("Cards");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Col).HasMaxLength(ColMaxLength);
|
||||
|
||||
// JSON-поля храним текстом с сериализованным JSON (как value_json настроек).
|
||||
builder.Property(x => x.StackJson).HasColumnType("text");
|
||||
builder.Property(x => x.ContactsJson).HasColumnType("text");
|
||||
builder.Property(x => x.MatchHitsJson).HasColumnType("text");
|
||||
builder.Property(x => x.LinksJson).HasColumnType("text");
|
||||
builder.Property(x => x.FilesJson).HasColumnType("text");
|
||||
builder.Property(x => x.HistoryJson).HasColumnType("text");
|
||||
builder.Property(x => x.SourceMsg).HasColumnType("text");
|
||||
|
||||
// Выборка колонки сортируется по времени получения (received_at DESC); счётчик новых — по (col, is_new).
|
||||
builder.HasIndex(x => new { x.Col, x.ReceivedAt }).IsDescending(false, true);
|
||||
builder.HasIndex(x => new { x.Col, x.IsNew });
|
||||
|
||||
// Сортировка пространства «Выбранные» — updated_at DESC.
|
||||
builder.HasIndex(x => x.UpdatedAt).IsDescending();
|
||||
|
||||
// Полнотекстовый вектор карточки (russian): Title+Summary+SourceMsg+Contact — вычисляемая STORED-
|
||||
// колонка (Ruling 6). Поиск /api/search идёт по SearchTsv @@ plainto_tsquery с LIKE-дополнением.
|
||||
builder.Property(x => x.SearchTsv)
|
||||
.HasComputedColumnSql(
|
||||
"to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))",
|
||||
stored: true);
|
||||
builder.HasIndex(x => x.SearchTsv).HasMethod("gin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация журнала действий над карточками: таблица CardMoves (модель без схемы).
|
||||
/// </summary>
|
||||
/// <remarks>Без внешних ключей: журнал живёт дольше карточки (прототип _hard_delete его не чистит).</remarks>
|
||||
public sealed class CardMoveConfiguration : IEntityTypeConfiguration<CardMoveEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<CardMoveEntity> builder)
|
||||
{
|
||||
builder.ToTable("CardMoves");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация единого контейнера: таблица Containers (модель без схемы).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Аддитивный слой этапа 9: единый реестр контейнеров (колонки/стадии/зоны) вместо прежних board-строк.
|
||||
/// переключение хранилища/сервисов — следующими задачами (T3/T4). SearchTsv — полнотекстовый индекс
|
||||
/// для поиска контейнеров (title/description), как Cards.SearchTsv.
|
||||
/// </remarks>
|
||||
public sealed class ContainerConfiguration : IEntityTypeConfiguration<ContainerEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ContainerEntity> builder)
|
||||
{
|
||||
builder.ToTable("Containers");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Kind).HasMaxLength(20);
|
||||
builder.Property(x => x.Space).HasMaxLength(20);
|
||||
|
||||
// JSON-поля храним текстом с сериализованным JSON (конвенция value_json).
|
||||
builder.Property(x => x.RulesJson).HasColumnType("text");
|
||||
builder.Property(x => x.PolicyJson).HasColumnType("text");
|
||||
builder.Property(x => x.Note).HasColumnType("text");
|
||||
|
||||
// Выборка пространства сортируется по позиции; ИИ-предложения — отдельно (как Boards).
|
||||
builder.HasIndex(x => new { x.Space, x.Position });
|
||||
builder.HasIndex(x => new { x.Suggested, x.Position });
|
||||
|
||||
// Полнотекстовый поиск по контейнерам (title/description) — STORED-колонка, как Cards.SearchTsv.
|
||||
builder.Property(x => x.SearchTsv)
|
||||
.HasComputedColumnSql(
|
||||
"to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))",
|
||||
stored: true);
|
||||
builder.HasIndex(x => x.SearchTsv).HasMethod("gin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Системный DbContext: схема public (тенанты, пользователи, сессии, операторы, инвайты, лимиты, аудит).
|
||||
/// </summary>
|
||||
public sealed class DealDbContext(DbContextOptions<DealDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<TenantEntity> Tenants => Set<TenantEntity>();
|
||||
|
||||
public DbSet<UserEntity> Users => Set<UserEntity>();
|
||||
|
||||
public DbSet<SessionEntity> Sessions => Set<SessionEntity>();
|
||||
|
||||
public DbSet<OperatorEntity> Operators => Set<OperatorEntity>();
|
||||
|
||||
public DbSet<OperatorSessionEntity> OperatorSessions => Set<OperatorSessionEntity>();
|
||||
|
||||
public DbSet<InviteEntity> Invites => Set<InviteEntity>();
|
||||
|
||||
public DbSet<TenantLimitEntity> TenantLimits => Set<TenantLimitEntity>();
|
||||
|
||||
public DbSet<AuditLogEntity> AuditLog => Set<AuditLogEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// История расхода токенов (time-series аналитики, этап 10, T2).
|
||||
/// </summary>
|
||||
public DbSet<TokenUsageEventEntity> TokenUsageEvents => Set<TokenUsageEventEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Счётчики фиксированного окна (этап 12, пакет B): распределённый rate-limit и учёт
|
||||
/// попыток входа (public.rate_limit_counters).
|
||||
/// </summary>
|
||||
public DbSet<RateLimitCounterEntity> RateLimitCounters => Set<RateLimitCounterEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Глобальные (системные) настройки оператора: ключи Telegram и др. (public.global_settings).
|
||||
/// </summary>
|
||||
public DbSet<GlobalSettingEntity> GlobalSettings => Set<GlobalSettingEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfiguration(new TenantConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new UserConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SessionConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new OperatorConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new OperatorSessionConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new InviteConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new TenantLimitConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new AuditLogConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new TokenUsageEventConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new RateLimitCounterConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new GlobalSettingConfiguration());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Фабрика для dotnet-ef (миграции). Читает строку подключения из env.
|
||||
/// </summary>
|
||||
public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory<DealDbContext>
|
||||
{
|
||||
public DealDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
|
||||
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
|
||||
var options = new DbContextOptionsBuilder<DealDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
return new DealDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация дедуп-хэшей пайплайна: таблица DedupEntries (модель без схемы).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Без внешних ключей: LeadId — «мягкая» ссылка на Cards; при жёстком удалении карточки строки чистит
|
||||
/// приложение (Ruling 3), чтобы «сирота» не блокировала повторное создание карточки.
|
||||
/// </remarks>
|
||||
public sealed class DedupEntryConfiguration : IEntityTypeConfiguration<DedupEntryEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DedupEntryEntity> builder)
|
||||
{
|
||||
builder.ToTable("DedupEntries");
|
||||
|
||||
builder.HasKey(x => x.Hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация каталога диалогов: таблица Dialogs (модель без схемы; Ruling 7).
|
||||
/// </summary>
|
||||
/// <remarks>Индексов нет — каталог читается целиком (список вкладки ≤500 диалогов) и по PK.</remarks>
|
||||
public sealed class DialogConfiguration : IEntityTypeConfiguration<DialogEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DialogEntity> builder)
|
||||
{
|
||||
builder.ToTable("Dialogs");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
// Дефолт цвета каталога (1:1 db.py L81 — hue VARCHAR NOT NULL DEFAULT '#666').
|
||||
builder.Property(x => x.Hue).HasDefaultValue("#666");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация чёрного списка Discovery: таблица DiscBlacklist (модель без схемы; Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 1:1 db.py L180–185. Список читается целиком (вкладка) и по PK (проверки add_candidate/воркера) — индексов
|
||||
/// не требуется. Повторная вставка того же диалога — ON CONFLICT DO UPDATE name/reason (python L572–575):
|
||||
/// адаптер реализует upsert кодом (CreatedAt сохраняется).
|
||||
/// </remarks>
|
||||
public sealed class DiscBlacklistConfiguration : IEntityTypeConfiguration<DiscBlacklistEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DiscBlacklistEntity> builder)
|
||||
{
|
||||
builder.ToTable("DiscBlacklist");
|
||||
|
||||
builder.HasKey(x => x.DialogId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация кандидата Discovery: таблица DiscCandidates (модель без схемы; Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 1:1 db.py L159–177. Marks/Topics — JSON-массивы в text. Составной индекс (TaskId, Status) — python
|
||||
/// idx_disc_cand_task L177: выборка кандидатов задачи по статусу (списки воркера/вкладки). Без FK на DiscTasks:
|
||||
/// удаление задачи чистит кандидатов каскадом в коде сервиса (delete_task L314–318).
|
||||
/// </remarks>
|
||||
public sealed class DiscCandidateConfiguration : IEntityTypeConfiguration<DiscCandidateEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DiscCandidateEntity> builder)
|
||||
{
|
||||
builder.ToTable("DiscCandidates");
|
||||
|
||||
builder.HasKey(x => x.DialogId);
|
||||
|
||||
builder.Property(x => x.MarksJson).HasColumnType("text");
|
||||
builder.Property(x => x.TopicsJson).HasColumnType("text");
|
||||
|
||||
builder.HasIndex(x => new { x.TaskId, x.Status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация лога Discovery: таблица DiscLog (модель без схемы; Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 1:1 db.py L189–196. Составной индекс (TaskId, CreatedAt) — python idx_disc_log_task L196: последние события
|
||||
/// задачи (ORDER BY created_at DESC). Без FK на DiscTasks: лог чистится каскадом delete_task в коде сервиса.
|
||||
/// </remarks>
|
||||
public sealed class DiscLogConfiguration : IEntityTypeConfiguration<DiscLogEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DiscLogEntity> builder)
|
||||
{
|
||||
builder.ToTable("DiscLog");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.HasIndex(x => new { x.TaskId, x.CreatedAt });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация задачи поиска Discovery: таблица DiscTasks (модель без схемы; Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 1:1 db.py L136–156. Keywords — JSON-массив в text (конвенция JSON-колонок этапов 1–5). Индексов нет —
|
||||
/// python idx для disc_tasks не создаёт: задачи читаются списком целиком (воркер/список вкладки) и по PK.
|
||||
/// </remarks>
|
||||
public sealed class DiscTaskConfiguration : IEntityTypeConfiguration<DiscTaskEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DiscTaskEntity> builder)
|
||||
{
|
||||
builder.ToTable("DiscTasks");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.KeywordsJson).HasColumnType("text");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Запись аудита (append-only) в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class AuditLogEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public DateTimeOffset At { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Тип актора: operator|tenant|system.
|
||||
/// </summary>
|
||||
public string ActorType { get; set; } = string.Empty;
|
||||
|
||||
public Guid? ActorId { get; set; }
|
||||
|
||||
public Guid? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Тип события — строковая константа каталога AuditEvents.
|
||||
/// </summary>
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
|
||||
public string? Ip { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Детали события в JSON (без секретов).
|
||||
/// </summary>
|
||||
public string? DetailJson { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Карточка канбана: таблица Cards в схеме тенанта. Соответствует таблице leads прототипа.
|
||||
/// </summary>
|
||||
public sealed class CardEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id карточки (префикс <c>c_</c>), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Колонка карточки: служебные <c>inbox|archive|trash|taken</c> либо id доски (<c>b_...</c>).
|
||||
/// Ссылочной целостности нет — существование доски валидирует приложение.
|
||||
/// </summary>
|
||||
public string Col { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Признак новой карточки (подсветка «новое» в колонке).
|
||||
/// </summary>
|
||||
public bool IsNew { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Признак «создано локально вручную» (карточка без внешнего первоисточника).
|
||||
/// </summary>
|
||||
public bool Local { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак «найм/разовое», проставленный эвристикой (маркерная гипотеза, не ИИ).
|
||||
/// </summary>
|
||||
public bool IsVacancy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True, когда тип «найм/разовое» подтверждён ИИ по контексту сообщения.
|
||||
/// </summary>
|
||||
public bool IsVacancyKnown { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Заголовок карточки (очищенный, до 140 символов — режет сервис).
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Краткое содержание карточки (очищенное, до 2000 символов — режет сервис).
|
||||
/// </summary>
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Стек/направления, сериализованные в JSON (text).
|
||||
/// </summary>
|
||||
public string StackJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Нижняя граница бюджета (валюта — BudgetCur), либо null.
|
||||
/// </summary>
|
||||
public double? BudgetFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Верхняя граница бюджета (валюта — BudgetCur), либо null.
|
||||
/// </summary>
|
||||
public double? BudgetTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Валюта бюджета (код или символ из исходного сообщения); пусто — бюджет не задан.
|
||||
/// </summary>
|
||||
public string BudgetCur { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Сконвертированная нижняя граница бюджета в целевую валюту, либо null.
|
||||
/// </summary>
|
||||
public double? ConvFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сконвертированная верхняя граница бюджета в целевую валюту, либо null.
|
||||
/// </summary>
|
||||
public double? ConvTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Валюта сконвертированного бюджета (целевая валюта тенанта); пусто — конверсия не сделана.
|
||||
/// </summary>
|
||||
public string ConvCur { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Контактная строка «как в сообщении» (fallback, если ContactsJson пуст).
|
||||
/// </summary>
|
||||
public string Contact { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Квалифицированные контакты, сериализованные в JSON (text).
|
||||
/// </summary>
|
||||
public string ContactsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Имя канала/диалога-источника.
|
||||
/// </summary>
|
||||
public string ChannelName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Handle канала-источника.
|
||||
/// </summary>
|
||||
public string ChannelHandle { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Цвет канала-источника (hex).
|
||||
/// </summary>
|
||||
public string ChannelHue { get; set; } = "#666";
|
||||
|
||||
/// <summary>
|
||||
/// Время получения исходного сообщения (сортировка карточек, автоархив).
|
||||
/// </summary>
|
||||
public DateTimeOffset ReceivedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Текст исходного сообщения (для переобучения ML и поиска).
|
||||
/// </summary>
|
||||
public string SourceMsg { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id диалога исходного сообщения (для «открыть исходник»).
|
||||
/// </summary>
|
||||
public string SourceDialogId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id исходного сообщения в Telegram, либо null.
|
||||
/// </summary>
|
||||
public long? SourceMsgId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Предыдущая колонка (для возврата из архива/корзины).
|
||||
/// </summary>
|
||||
public string PrevCol { get; set; } = "inbox";
|
||||
|
||||
/// <summary>
|
||||
/// Время помещения в архив (для правила «архив очищается через N дней»), либо null.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ArchivedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Совпавшие критерии правил при попадании в колонку, сериализованные в JSON (text).
|
||||
/// </summary>
|
||||
public string MatchHitsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Ссылки карточки, сериализованные в JSON (text; элементы {id,name,url}).
|
||||
/// </summary>
|
||||
public string LinksJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Файлы карточки, сериализованные в JSON (text; элементы {id,name,size,kind,label,objectKey}).
|
||||
/// </summary>
|
||||
public string FilesJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// История движения карточки, сериализованная в JSON (text; элементы {id,at,type|stage}).
|
||||
/// </summary>
|
||||
public string HistoryJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Текст технического задания по карточке (заметка-задание).
|
||||
/// </summary>
|
||||
public string TzText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время напоминания об отложенной карточке, либо null (напоминание не задано/сброшено).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReminderAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак «напоминание уже выстрелило» (повторно не срабатывает до переноса/переустановки).
|
||||
/// </summary>
|
||||
public bool ReminderFired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Полнотекстовый вектор (tsvector, конфигурация russian) для поиска карточек — вычисляемая STORED-колонка БД.
|
||||
/// </summary>
|
||||
public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время создания карточки.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время последнего изменения карточки (сортировка пространства «Выбранные» — UpdatedAt DESC).
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Запись журнала действий над карточкой: таблица CardMoves в схеме тенанта. Соответствует таблице learning_log прототипа.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Журнал живёт дольше карточки (прототип <c>_hard_delete</c> его не чистит), поэтому ссылки на
|
||||
/// карточку внешним ключом не связаны — только значение LeadId.
|
||||
/// </remarks>
|
||||
public sealed class CardMoveEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id записи журнала (префикс <c>lm_</c>), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id карточки (Cards.Id), над которой выполнено действие. Без FK — журнал хранится и после удаления карточки.
|
||||
/// </summary>
|
||||
public string LeadId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Действие: <c>move|trash|restore|comment</c> (счётчик learning = число записей).
|
||||
/// </summary>
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Колонка-источник переноса, либо null (комментарий).
|
||||
/// </summary>
|
||||
public string? FromCol { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Колонка-назначение переноса, либо null (комментарий).
|
||||
/// </summary>
|
||||
public string? ToCol { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Единый контейнер карточек: колонка дашборда, стадия «Выбранных» или служебная зона (таблица Containers).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Приходит на смену разрозненным сущностям: Boards (колонки дашборда) + предзаданный каталог стадий
|
||||
/// (planned…rejected, жил константой модуля) + строковые служебные зоны (inbox/archive/trash — жили
|
||||
/// значениями Cards.Col). Одна таблица: kind (board|stage|service|terminal), space (dashboard|selected),
|
||||
/// правила фильтрации (RulesJson), политика (PolicyJson: CanRestore/IsTerminal/RetentionDays).
|
||||
/// Служебные и стадии провижининг сидирует из реестров модуля Cards (CardsDefaultContainers/CardIds);
|
||||
/// доски создаёт пользователь/ИИ (kind=board, как Boards раньше).
|
||||
/// </remarks>
|
||||
public sealed class ContainerEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id контейнера (доски <c>b_…</c>, стадии <c>planned…</c>, служебные inbox/archive/trash).
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Имя для отображения («WPF», «В работе», «Архив»).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Описание контейнера (для пользователя и подсказки ИИ/ML).
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Цвет (hex).
|
||||
/// </summary>
|
||||
public string Color { get; set; } = "#818cf8";
|
||||
|
||||
/// <summary>
|
||||
/// Позиция в пространстве (ORDER BY space, position).
|
||||
/// </summary>
|
||||
public int Position { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Вид контейнера: board (колонка-фильтр) | stage (стадия) | service (inbox/archive/trash) | terminal (finished/rejected).
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = "board";
|
||||
|
||||
/// <summary>
|
||||
/// Пространство: dashboard | selected (вид дашборда, к которому принадлежит контейнер).
|
||||
/// </summary>
|
||||
public string Space { get; set; } = "dashboard";
|
||||
|
||||
/// <summary>
|
||||
/// Свёрнутость колонки на дашборде (состояние UI).
|
||||
/// </summary>
|
||||
public bool Collapsed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак ИИ-предложения: контейнер ждёт решения пользователя.
|
||||
/// </summary>
|
||||
public bool Suggested { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Правила маршрутизации (IContainerRules), сериализованные в JSON (text).
|
||||
/// </summary>
|
||||
public string RulesJson { get; set; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Заметка контейнера (например, сгенерированное описание правил / обоснование ИИ).
|
||||
/// </summary>
|
||||
public string Note { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Политика контейнера (CanRestore/IsTerminal/RetentionDays), сериализованная в JSON (text).
|
||||
/// </summary>
|
||||
public string PolicyJson { get; set; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Полнотекстовый вектор поиска по контейнерам (title/description) — вычисляемая STORED-колонка.
|
||||
/// </summary>
|
||||
public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Дедуп-хэш текста сообщения: таблица DedupEntries в схеме тенанта. Соответствует таблице dedup прототипа.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Защита от повторного заведения карточки (одинаковый текст дважды). LeadId — «мягкая» ссылка на Cards без FK:
|
||||
/// чистку строки при жёстком удалении карточки выполняет приложение (Ruling 3 этапа 4).
|
||||
/// </remarks>
|
||||
public sealed class DedupEntryEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Хэш нормализованного текста (SHA1 hex, без префикса), первичный ключ.
|
||||
/// </summary>
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id созданной карточки (<c>l_...</c>), либо null — хэш занят в обработке (claim).
|
||||
/// </summary>
|
||||
public string? LeadId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время записи/занятия хэша.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Диалог/канал каталога тенанта: таблица Dialogs в схеме тенанта (Ruling 7, db.py L76–86).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Зеркало каталога диалогов аккаунта Telegram: владелец — модуль Deal.Modules.Telegram (Task 13). Kind хранит
|
||||
/// EN-канон контракта (channel|group|forum|chat) — 1:1 с entries SyncDialogs/refresh (proto DialogEntry).
|
||||
/// Без FK — каталог независим от сообщений/карточек.
|
||||
/// </remarks>
|
||||
public sealed class DialogEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Отображаемое имя диалога (title/first_name).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Username (handle) источника; пуст, если нет публичного username.
|
||||
/// </summary>
|
||||
public string Handle { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Тип источника: channel|group|forum|chat (EN-канон telegram.proto).
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Цвет источника из палитры DIALOG_HUES (hex «#rrggbb»); дефолт «#666» (db.py L81).
|
||||
/// </summary>
|
||||
public string Hue { get; set; } = "#666";
|
||||
|
||||
/// <summary>
|
||||
/// Признак мониторинга: сообщения диалога → PushMessage в очередь пайплайна (db.py L82).
|
||||
/// </summary>
|
||||
public bool Monitor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Текст последнего принятого сообщения (обрезается до 200, python L272).
|
||||
/// </summary>
|
||||
public string LastText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Момент последнего принятого сообщения (UTC); null — сообщений ещё не было.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак «канал разобран» (первый backfill завершён; python ALTER backfilled, L284).
|
||||
/// </summary>
|
||||
public bool Backfilled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент последнего изменения строки (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Чёрный список Discovery: таблица DiscBlacklist в схеме тенанта (db.py L180–185, Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Владелец — модуль Deal.Modules.Discovery (Task 17). Список общий для всех задач: источники из него
|
||||
/// пропускаются поиском (add_candidate) и повторной проверкой перед авто-вступлением (воркер). Снимается
|
||||
/// вручную или при ручном join. Повторное добавление обновляет Name/Reason и сохраняет CreatedAt
|
||||
/// (ON CONFLICT DO UPDATE — python L572–575).
|
||||
/// </remarks>
|
||||
public sealed class DiscBlacklistEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Подписанный id источника, первичный ключ.
|
||||
/// </summary>
|
||||
public string DialogId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Имя источника (пусто → DialogId).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Причина добавления («отклонено вручную», метка воркера).
|
||||
/// </summary>
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Момент первого добавления (UTC; при перезаписи сохраняется).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Кандидат задачи Discovery: таблица DiscCandidates в схеме тенанта (db.py L159–176, Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Владелец — модуль Deal.Modules.Discovery (Task 17). Первичный ключ — DialogId (источник может быть кандидатом
|
||||
/// только одной задачи/одного статуса — python). Marks/Topics — JSON-колонки (text): marks — строки-метки оценки,
|
||||
/// topics — элементы {topicId,title,fitCount,total,fitRatio,passed} для форумов. Status: new|review|joined|rejected.
|
||||
/// Без FK — DiscTasks/Dialogs удаляются/живут независимо (каталог кандидата может пережить задачу до delete_task).
|
||||
/// </remarks>
|
||||
public sealed class DiscCandidateEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Подписанный id источника (каналы «-100…», группы «-…»), первичный ключ.
|
||||
/// </summary>
|
||||
public string DialogId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id задачи поиска, которой принадлежит кандидат.
|
||||
/// </summary>
|
||||
public string TaskId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Отображаемое имя источника (пусто → DialogId).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Username (handle) источника; пуст, если нет публичного username.
|
||||
/// </summary>
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Тип источника: channel|group|forum.
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = "channel";
|
||||
|
||||
/// <summary>
|
||||
/// Цвет источника из палитры DIALOG_HUES (hex «#rrggbb»); дефолт «#666».
|
||||
/// </summary>
|
||||
public string Hue { get; set; } = "#666";
|
||||
|
||||
/// <summary>
|
||||
/// Число участников источника; null — неизвестно (до discovery_info).
|
||||
/// </summary>
|
||||
public int? Participants { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Язык источника: true — русский, false — не русский; null — не определён.
|
||||
/// </summary>
|
||||
public bool? LangRu { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Метки оценки, сериализованные в JSON (text; дефолт «[]»).
|
||||
/// </summary>
|
||||
public string MarksJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Оценка тем форума, сериализованная в JSON (text; дефолт «[]»).
|
||||
/// </summary>
|
||||
public string TopicsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Доля подходящих сообщений оценки (0..1); null — контент не оценён.
|
||||
/// </summary>
|
||||
public double? FitRatio { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Статус кандидата: new|review|joined|rejected.
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "new";
|
||||
|
||||
/// <summary>
|
||||
/// Вступили автоматически (воркером); false — вручную.
|
||||
/// </summary>
|
||||
public bool AutoJoined { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Неудачные авто-вступления подряд (3 → кандидат удаляется, Task 18).
|
||||
/// </summary>
|
||||
public int JoinFailures { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент добавления кандидата (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент последнего изменения (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Лог событий задачи Discovery: таблица DiscLog в схеме тенанта (db.py L189–195, Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Владелец — модуль Deal.Modules.Discovery (Task 17). Event — каталог модуля: search|skip|review|join_auto|
|
||||
/// join_manual|leave|reject|flood|error|done. Чтение — последние события задачи (ORDER BY created_at DESC),
|
||||
/// поэтому создан индекс (TaskId, CreatedAt) — python idx_disc_log_task L196.
|
||||
/// </remarks>
|
||||
public sealed class DiscLogEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id записи (префикс <c>dl_</c>), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id задачи поиска.
|
||||
/// </summary>
|
||||
public string TaskId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Событие (search|skip|review|join_auto|join_manual|leave|reject|flood|error|done).
|
||||
/// </summary>
|
||||
public string Event { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Текст/детали события (русская строка 1:1 с прототипом).
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Момент события (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Задача поиска Discovery: таблица DiscTasks в схеме тенанта (db.py L136–156, Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Владелец — модуль Deal.Modules.Discovery (Task 17). Keywords хранит JSON-массив (text); search_*/счётчики —
|
||||
/// живой прогресс по задаче (воркер Task 18). Status: draft|running|paused|done|failed (каталог модуля).
|
||||
/// </remarks>
|
||||
public sealed class DiscTaskEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id задачи (префикс <c>dt_</c>), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Название задачи (обязательное, Trim).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Описание ниши/цели (источник для ИИ-генерации ключей).
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Ключевые слова поиска, сериализованные в JSON (text; дефолт «[]»).
|
||||
/// </summary>
|
||||
public string KeywordsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Минимальное число участников источника (0 — не фильтровать).
|
||||
/// </summary>
|
||||
public int MinSubscribers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Язык источников: ru|any.
|
||||
/// </summary>
|
||||
public string Lang { get; set; } = "ru";
|
||||
|
||||
/// <summary>
|
||||
/// Порог подходящих сообщений оценки, % (1..100; дефолт 40 — discEvalThreshold).
|
||||
/// </summary>
|
||||
public int Threshold { get; set; } = 40;
|
||||
|
||||
/// <summary>
|
||||
/// Размер выборки сообщений при оценке (дефолт 10 — discEvalSample).
|
||||
/// </summary>
|
||||
public int SampleSize { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// План авто-вступлений (1..discJoinLimit; занимает суточный бюджет).
|
||||
/// </summary>
|
||||
public int PlanJoins { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Авто-вступления воркером включены.
|
||||
/// </summary>
|
||||
public bool AutoJoin { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Статус задачи: draft|running|paused|done|failed.
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "draft";
|
||||
|
||||
/// <summary>
|
||||
/// Индекс текущего ключа поиска (прогресс прохода по keywords).
|
||||
/// </summary>
|
||||
public int SearchIdx { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Проход по всем ключам завершён.
|
||||
/// </summary>
|
||||
public bool SearchDone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Найдено кандидатов поиском.
|
||||
/// </summary>
|
||||
public int Found { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Оценено/пропущено кандидатов.
|
||||
/// </summary>
|
||||
public int Evaluated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Вступили (joined ≥ plan_joins → задача done).
|
||||
/// </summary>
|
||||
public int Joined { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Отклонено кандидатов.
|
||||
/// </summary>
|
||||
public int Rejected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент создания задачи (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент последнего изменения (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Глобальная (системная) настройка оператора: таблица public.global_settings.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единое KV-хранилище всего SaaS-контура (ТЗ §4.1/§8.1): значения задаёт оператор, видят все
|
||||
/// тенанты. Секреты хранятся зашифрованными (префикс <c>enc:</c>), формат значения определяет ключ
|
||||
/// (<see cref="Deal.Modules.Settings.Application.GlobalSettingsKeys"/>).
|
||||
/// </remarks>
|
||||
public sealed class GlobalSettingEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Ключ глобальной настройки (PK).
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Значение настройки, сериализованное в JSON.
|
||||
/// </summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время последнего изменения (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Приглашение на регистрацию (invite) в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class InviteEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Одноразовый код приглашения (url-safe, 16 симв.) — первичный ключ.
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Email приглашённого, нормализованный (нижний регистр); уникален среди активных.
|
||||
/// </summary>
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Целевой тенант; null — при активации создаётся новый тенант.
|
||||
/// </summary>
|
||||
public Guid? TenantId { get; set; }
|
||||
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент активации (null, пока инвайт не использован).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ActivatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Оператор, создавший приглашение.
|
||||
/// </summary>
|
||||
public Guid CreatedById { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Комментарий карточки: таблица LeadComments в схеме тенанта. Нормализация массива comments строки leads прототипа.
|
||||
/// </summary>
|
||||
public sealed class LeadCommentEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id комментария (префикс <c>cm_</c>), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id карточки (Cards.Id), внешний ключ с каскадным удалением.
|
||||
/// </summary>
|
||||
public string CardId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Автор комментария (в прототипе — «Вы»), отдаётся как <c>by</c>.
|
||||
/// </summary>
|
||||
public string By { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Текст комментария.
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время добавления комментария (человеческую метку <c>time</c> считает маппинг).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Строка очереди обучающих сигналов ML: таблица MlOutbox в схеме тенанта. Соответствует таблице ml_outbox прототипа.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Действия пользователя всегда пишутся сюда синхронно; фоновый воркер отправляет строки в ML-сервис
|
||||
/// (этап 3 — только накопление; отправка — этап 6). Без FK — очередь не зависит от карточек.
|
||||
/// </remarks>
|
||||
public sealed class MlOutboxEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id записи outbox (префикс <c>mle_</c>), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Текст обучающего примера (обрезается до 6000 символов при записи).
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Метка обучения: id доски (<c>b_...</c>), <c>spam</c> либо <c>t:hire|t:order</c>.
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Весовой коэффициент сигнала (1.0 — учить, −1.0 — снять метку).
|
||||
/// </summary>
|
||||
public double Delta { get; set; } = 1.0;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Оператор (администратор SaaS-контура) в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class OperatorEntity
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// Логин оператора: уникальный, хранится в нижнем регистре.
|
||||
/// </summary>
|
||||
public string Login { get; set; } = string.Empty;
|
||||
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Сессия оператора в системной схеме public. Хранится SHA-256-хеш токена.
|
||||
/// </summary>
|
||||
public sealed class OperatorSessionEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// SHA-256-хеш токена сессии оператора (первичный ключ).
|
||||
/// </summary>
|
||||
public string TokenHash { get; set; } = string.Empty;
|
||||
|
||||
public Guid OperatorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Денормализованный логин оператора — для чтения /me без join.
|
||||
/// </summary>
|
||||
public string Login { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Строка очереди входящих пайплайна: таблица QueueItems в схеме тенанта. Соответствует таблице pipeline_msg прототипа.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Все сообщения из групп попадают сюда и разбираются фоновым воркером; не прошедшие фильтры удаляются сразу
|
||||
/// (не копятся). force=true — сообщение возвращено из отсева: фильтры-отсев для него игнорируются (уходит на ML/ИИ).
|
||||
/// Без FK — очередь не зависит от карточек/диалогов.
|
||||
/// </remarks>
|
||||
public sealed class QueueItemEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id строки очереди (префикс <c>p_</c>), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id диалога-источника (для «открыть исходник» и дубль-гварда по msgId при приёме).
|
||||
/// </summary>
|
||||
public string DialogId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Имя канала/диалога-источника.
|
||||
/// </summary>
|
||||
public string ChannelName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Handle канала-источника.
|
||||
/// </summary>
|
||||
public string ChannelHandle { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Цвет канала-источника (hex).
|
||||
/// </summary>
|
||||
public string ChannelHue { get; set; } = "#666";
|
||||
|
||||
/// <summary>
|
||||
/// Текст сообщения (обрезается до 6000 символов при приёме — режет сервис).
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id исходного сообщения в Telegram, либо null (защита от двойного события Telethon).
|
||||
/// </summary>
|
||||
public long? MsgId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время получения исходного сообщения (stale-проверка правила автоархива).
|
||||
/// </summary>
|
||||
public DateTimeOffset MsgAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Статус строки: <c>new</c> — ждёт разбора воркером, <c>filtered</c> — прошла фильтры и ждёт ИИ/ML.
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "new";
|
||||
|
||||
/// <summary>
|
||||
/// Признак возврата из отсева: фильтры-отсев для строки игнорируются.
|
||||
/// </summary>
|
||||
public bool Force { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время постановки в очередь.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время последнего изменения строки.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Счётчик фиксированного окна в системной схеме public (этап 12, пакет B).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Используется распределённым rate limiting (auth/api/gRPC-ингресс) и guard'ом попыток входа: значение
|
||||
/// общего счётчика видно всем инстансам core (ранее — память одного процесса). Строка живёт до
|
||||
/// <see cref="ExpiresAt"/> (windowStart + длина окна), после чего удаляется фоновой уборкой.
|
||||
/// </remarks>
|
||||
public sealed class RateLimitCounterEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Уникальный ключ счётчика (префикс политики/типа + партиция: IP, tenant-id или ip|login).
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Начало текущего фиксированного окна (UTC; выровнено по длине окна).
|
||||
/// </summary>
|
||||
public DateTimeOffset WindowStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент, после которого строка считается устаревшей (windowStart + длина окна).
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Число потреблённых единиц в текущем окне.
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Запись отсева пайплайна: таблица RejectedItems в схеме тенанта. Соответствует таблице rejected_msgs прототипа.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Сообщения, не прошедшие этапы обработки (стоп-лист/резюме/тип/без суммы/устарело/ML/ИИ): хранится причина
|
||||
/// и «чьё» решение (Source). Автоочистка раз в 3 суток + ручная очистка из UI. При возврате в обработку запись
|
||||
/// не удаляется — помечается returned/returnedAt/returnReason. Без FK — отсев живёт дольше карточки (конвенция
|
||||
/// Ruling 1 этапа 3).
|
||||
/// </remarks>
|
||||
public sealed class RejectedItemEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id записи (префикс <c>r_</c>), первичный ключ.
|
||||
/// </summary>
|
||||
/// <remarks>Детерминированный <c>r_<dialog>_<msgId></c> при наличии dialog+msgId, иначе <c>r_</c>+hex (upsert по id).</remarks>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id диалога-источника (для повторного возврата в очередь); пусто — диалог неизвестен.
|
||||
/// </summary>
|
||||
public string DialogId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id исходного сообщения в Telegram, либо null.
|
||||
/// </summary>
|
||||
public long? MsgId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Имя канала/диалога-источника.
|
||||
/// </summary>
|
||||
public string ChannelName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Handle канала-источника.
|
||||
/// </summary>
|
||||
public string ChannelHandle { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Цвет канала-источника (hex).
|
||||
/// </summary>
|
||||
public string ChannelHue { get; set; } = "#666";
|
||||
|
||||
/// <summary>
|
||||
/// Текст сообщения (обрезается до 6000 символов при записи — режет сервис).
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Этап отсева (<c>length|stop|resume|type|budget|stale|dup|spam_ml|spam_ai|filter_ai</c> и т.п.).
|
||||
/// </summary>
|
||||
public string Stage { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Человекочитаемая причина отсева (до 500 символов — режет сервис).
|
||||
/// </summary>
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Совпавшее ключевое слово/фраза правила (до 200 символов), либо пусто.
|
||||
/// </summary>
|
||||
public string Kw { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Кто вынес решение: <c>stop|ml|ai|stale|dup</c> (stale/dup — «система»).
|
||||
/// </summary>
|
||||
public string Source { get; set; } = "stop";
|
||||
|
||||
/// <summary>
|
||||
/// Время получения исходного сообщения (для повторного возврата в очередь).
|
||||
/// </summary>
|
||||
public DateTimeOffset MsgAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время записи в отсев (автоочистка старше 3 суток).
|
||||
/// </summary>
|
||||
public DateTimeOffset RejectedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак возврата записи в обработку пользователем.
|
||||
/// </summary>
|
||||
public bool Returned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время возврата в обработку, либо null.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReturnedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Причина возврата пользователем (до 500 символов — режет сервис), либо пусто.
|
||||
/// </summary>
|
||||
public string ReturnReason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Полнотекстовый вектор (tsvector, конфигурация russian) для поиска отсева — вычисляемая STORED-колонка БД.
|
||||
/// </summary>
|
||||
public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Сессия пользователя в системной схеме public. Хранится SHA-256-хеш токена.
|
||||
/// </summary>
|
||||
public sealed class SessionEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// SHA-256-хеш токена сессии (первичный ключ).
|
||||
/// </summary>
|
||||
public string TokenHash { get; set; } = string.Empty;
|
||||
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Денормализованный логин пользователя — для чтения /me без join.
|
||||
/// </summary>
|
||||
public string Login { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Маркер impersonation (план Task 7): оператор, создавший сессию; null — обычная сессия.
|
||||
/// Нужен для аудита impersonation_stopped при logout (AuthService/LogoutAsync).
|
||||
/// </summary>
|
||||
public Guid? ImpersonatedByOperatorId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Тенант в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class TenantEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит ИИ-бюджета тенанта в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class TenantLimitEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Тенант, которому принадлежит лимит (первичный ключ).
|
||||
/// </summary>
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Бюджет периода в токенах.
|
||||
/// </summary>
|
||||
public long BudgetTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Тип периода: month|day.
|
||||
/// </summary>
|
||||
public string Period { get; set; } = "month";
|
||||
|
||||
/// <summary>
|
||||
/// Начало текущего периода (отсчёт от него — срок и ленивый reset).
|
||||
/// </summary>
|
||||
public DateTimeOffset PeriodStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Использовано токенов с начала периода.
|
||||
/// </summary>
|
||||
public long UsedTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Флаг: тост о расходе 80% бюджета уже отправлен (один на период).
|
||||
/// </summary>
|
||||
public bool Warned80 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Флаг: тост об исчерпании бюджета уже отправлен (один на период).
|
||||
/// </summary>
|
||||
public bool NotifiedExhausted { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Настройка тенанта: таблица settings в схеме тенанта.
|
||||
/// </summary>
|
||||
public sealed class TenantSettingEntity
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Значение настройки, сериализованное в JSON.
|
||||
/// </summary>
|
||||
public string ValueJson { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Строка превью сообщения диалога: таблица TgMessages в схеме тенанта (Ruling 7, db.py L67–74).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Превью сообщений принятых PushMessage/разборов — фолбэк вкладки «Каналы» (preview) и «последнее сообщение»
|
||||
/// каталога. Id — «m_<dialog>_<msg>» (python L604). LeadId — мягкая ссылка на карточку по сообщению
|
||||
/// (без FK — карточки живут в другой таблице и могут удаляться).
|
||||
/// </remarks>
|
||||
public sealed class TgMessageEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Id строки превью («m_<dialog>_<msg>»), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id диалога-источника.
|
||||
/// </summary>
|
||||
public string DialogId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Текст сообщения (обрезается до 4000 при записи, python L604).
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время сообщения (UTC; у python — epoch-ms).
|
||||
/// </summary>
|
||||
public DateTimeOffset MsgAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Id карточки, созданной по сообщению (мягкая ссылка), либо null.
|
||||
/// </summary>
|
||||
public string? LeadId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Событие расхода токенов (time-series) в системной схеме public (этап 10, T2).
|
||||
/// </summary>
|
||||
public sealed class TokenUsageEventEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Тенант события (Guid строки public.tenants).
|
||||
/// </summary>
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
public DateTimeOffset At { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Провайдер/источник: deepseek/openai/anthropic/local/ml.
|
||||
/// </summary>
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Модель провайдера.
|
||||
/// </summary>
|
||||
public string Model { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Вид вызова: ai|ml (TokenUsageEventKinds).
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
public long PromptTokens { get; set; }
|
||||
|
||||
public long CompletionTokens { get; set; }
|
||||
|
||||
public long TotalTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Детали события в JSON (без секретов).
|
||||
/// </summary>
|
||||
public string? DetailJson { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь тенанта в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class UserEntity
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// Логин пользователя: уникальный, хранится в нижнем регистре.
|
||||
/// </summary>
|
||||
public string Login { get; set; } = string.Empty;
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация глобальной (системной) настройки оператора: таблица global_settings
|
||||
/// в схеме public (ТЗ §4.1/§8.1).
|
||||
/// </summary>
|
||||
public sealed class GlobalSettingConfiguration : IEntityTypeConfiguration<GlobalSettingEntity>
|
||||
{
|
||||
private const int KeyMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<GlobalSettingEntity> builder)
|
||||
{
|
||||
builder.ToTable("global_settings", "public");
|
||||
|
||||
builder.HasKey(x => x.Key);
|
||||
|
||||
builder.Property(x => x.Key).HasMaxLength(KeyMaxLength);
|
||||
builder.Property(x => x.Value).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.UpdatedAt).IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация приглашения: таблица invites в схеме public.
|
||||
/// </summary>
|
||||
public sealed class InviteConfiguration : IEntityTypeConfiguration<InviteEntity>
|
||||
{
|
||||
private const int EmailMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<InviteEntity> builder)
|
||||
{
|
||||
builder.ToTable("invites", "public");
|
||||
|
||||
builder.HasKey(x => x.Code);
|
||||
|
||||
builder.Property(x => x.Email).HasMaxLength(EmailMaxLength).IsRequired();
|
||||
builder.Property(x => x.TenantId);
|
||||
builder.Property(x => x.Status).IsRequired().HasDefaultValue("pending");
|
||||
builder.Property(x => x.ExpiresAt).IsRequired();
|
||||
builder.Property(x => x.ActivatedAt);
|
||||
builder.Property(x => x.CreatedAt).IsRequired();
|
||||
|
||||
// Email уникален среди активных (pending) инвайтов: после активации/отзыва/истечения он
|
||||
// освобождается — глобальную уникальность регистрации держит unique-индекс users.Login.
|
||||
builder.HasIndex(x => x.Email).IsUnique().HasFilter("\"Status\" = 'pending'");
|
||||
|
||||
builder.HasOne<OperatorEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.CreatedById)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация комментария карточки: таблица LeadComments (модель без схемы).
|
||||
/// </summary>
|
||||
public sealed class LeadCommentConfiguration : IEntityTypeConfiguration<LeadCommentEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LeadCommentEntity> builder)
|
||||
{
|
||||
builder.ToTable("LeadComments");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
// Комментарии карточки читаются вместе с ней; удаление карточки каскадно чистит комментарии (Ruling 10).
|
||||
builder.HasOne<CardEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.CardId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(x => x.CardId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация очереди обучающих сигналов ML: таблица MlOutbox (модель без схемы).
|
||||
/// </summary>
|
||||
/// <remarks>Без внешних ключей: очередь не зависит от карточек и чистится отдельно (ResetAsync).</remarks>
|
||||
public sealed class MlOutboxConfiguration : IEntityTypeConfiguration<MlOutboxEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MlOutboxEntity> builder)
|
||||
{
|
||||
builder.ToTable("MlOutbox");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
// Очистка/выборка outbox идёт по времени создания.
|
||||
builder.HasIndex(x => x.CreatedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация оператора: таблица operators в схеме public.
|
||||
/// </summary>
|
||||
public sealed class OperatorConfiguration : IEntityTypeConfiguration<OperatorEntity>
|
||||
{
|
||||
private const int LoginMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<OperatorEntity> builder)
|
||||
{
|
||||
builder.ToTable("operators", "public");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired();
|
||||
builder.Property(x => x.PasswordHash).IsRequired();
|
||||
builder.Property(x => x.Status).IsRequired().HasDefaultValue("active");
|
||||
builder.Property(x => x.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasIndex(x => x.Login).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация сессии оператора: таблица operator_sessions в схеме public.
|
||||
/// </summary>
|
||||
public sealed class OperatorSessionConfiguration : IEntityTypeConfiguration<OperatorSessionEntity>
|
||||
{
|
||||
private const int TokenHashMaxLength = 64;
|
||||
private const int LoginMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<OperatorSessionEntity> builder)
|
||||
{
|
||||
builder.ToTable("operator_sessions", "public");
|
||||
|
||||
builder.HasKey(x => x.TokenHash);
|
||||
|
||||
builder.Property(x => x.TokenHash).HasMaxLength(TokenHashMaxLength);
|
||||
builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired();
|
||||
builder.Property(x => x.ExpiresAt).IsRequired();
|
||||
builder.Property(x => x.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasIndex(x => x.OperatorId);
|
||||
builder.HasIndex(x => x.ExpiresAt);
|
||||
|
||||
builder.HasOne<OperatorEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.OperatorId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация очереди входящих пайплайна: таблица QueueItems (модель без схемы).
|
||||
/// </summary>
|
||||
/// <remarks>Без внешних ключей: очередь не зависит от карточек и чистится воркером/очистками.</remarks>
|
||||
public sealed class QueueItemConfiguration : IEntityTypeConfiguration<QueueItemEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<QueueItemEntity> builder)
|
||||
{
|
||||
builder.ToTable("QueueItems");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
// Текст сообщения — text; лимит 6000 символов применяет сервис при приёме (Ruling 2), не БД.
|
||||
builder.Property(x => x.Text).HasColumnType("text");
|
||||
|
||||
// Выборка pump'а идёт по статусу и времени постановки (status='new', лимит 12).
|
||||
builder.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация счётчика фиксированного окна: таблица rate_limit_counters в схеме public
|
||||
/// (этап 12, пакет B — распределённый rate-limit и учёт попыток входа).
|
||||
/// </summary>
|
||||
public sealed class RateLimitCounterConfiguration : IEntityTypeConfiguration<RateLimitCounterEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RateLimitCounterEntity> builder)
|
||||
{
|
||||
builder.ToTable("rate_limit_counters", "public");
|
||||
|
||||
builder.HasKey(x => x.Key);
|
||||
builder.Property(x => x.Key).HasColumnType("text");
|
||||
builder.Property(x => x.WindowStart).IsRequired();
|
||||
builder.Property(x => x.ExpiresAt).IsRequired();
|
||||
builder.Property(x => x.Count).IsRequired();
|
||||
|
||||
// Индекс для фоновой уборки устаревших окон (DeleteExpiredAsync по ExpiresAt).
|
||||
builder.HasIndex(x => x.ExpiresAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация отсева пайплайна: таблица RejectedItems (модель без схемы).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Без внешних ключей — отсев живёт дольше карточки. Тексты Text/Reason/Kw — text (лимиты 6000/500/200
|
||||
/// символов применяет сервис). SearchTsv — вычисляемая STORED-колонка tsvector (Ruling 6).
|
||||
/// </remarks>
|
||||
public sealed class RejectedItemConfiguration : IEntityTypeConfiguration<RejectedItemEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RejectedItemEntity> builder)
|
||||
{
|
||||
builder.ToTable("RejectedItems");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Text).HasColumnType("text");
|
||||
builder.Property(x => x.Reason).HasColumnType("text");
|
||||
builder.Property(x => x.Kw).HasColumnType("text");
|
||||
|
||||
// Полнотекстовый вектор отсева: только Text (fts.py _FTS_TARGETS) — поиск GET /pipeline/rejected?q=.
|
||||
builder.Property(x => x.SearchTsv)
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", stored: true);
|
||||
|
||||
// Автоочистка старше 3 суток и сортировка списка идут по времени отсева.
|
||||
builder.HasIndex(x => x.RejectedAt);
|
||||
|
||||
// Полнотекстовый поиск отсева — GIN-индекс по tsvector (Ruling 6).
|
||||
builder.HasIndex(x => x.SearchTsv).HasMethod("gin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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.audit_log (append-only, Ruling 4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). Запись — только
|
||||
/// Add+SaveChanges; выборка — фильтры At-range/EventType/ActorType/TenantId/ActorId, сортировка At DESC, offset/limit
|
||||
/// (limit клампится 1..<see cref="AuditService.MaxQueryLimit"/>, offset ≥0). Update/Delete в приложении отсутствуют (append-only на уровне
|
||||
/// кода и конвенции; DB-триггеры не добавляем, Ruling 4).
|
||||
/// </remarks>
|
||||
public sealed class AuditLogStore(DealDbContext dbContext) : IAuditLogStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task AppendAsync(AuditRecordDto record, CancellationToken ct)
|
||||
{
|
||||
dbContext.AuditLog.Add(new AuditLogEntity
|
||||
{
|
||||
// Id генерирует БД (identity) — из DTO не копируется.
|
||||
At = record.At,
|
||||
ActorType = record.ActorType,
|
||||
ActorId = record.ActorId,
|
||||
TenantId = record.TenantId,
|
||||
EventType = record.EventType,
|
||||
Ip = record.Ip,
|
||||
DetailJson = record.DetailJson,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AuditRecordDto>> QueryAsync(AuditQueryDto filter, CancellationToken ct)
|
||||
{
|
||||
var entities = await ApplyFilters(dbContext.AuditLog.AsNoTracking(), filter)
|
||||
.OrderByDescending(a => a.At)
|
||||
.Skip(Math.Max(0, filter.Offset))
|
||||
.Take(ClampLimit(filter.Limit))
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(ToAuditRecordDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct)
|
||||
{
|
||||
return await ApplyFilters(dbContext.AuditLog.AsNoTracking(), filter).CountAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> PurgeOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct)
|
||||
{
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
// Операционная авто-очистка: удаляем только устаревшие строки одним DELETE.
|
||||
return await dbContext.AuditLog.Where(a => a.At < cutoff).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
// InMemory-провайдер (тесты) ExecuteDelete не исполняет — выгрузка и RemoveRange.
|
||||
List<AuditLogEntity> expired = await dbContext.AuditLog
|
||||
.Where(a => a.At < cutoff)
|
||||
.ToListAsync(ct);
|
||||
if (expired.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
dbContext.AuditLog.RemoveRange(expired);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
return expired.Count;
|
||||
}
|
||||
|
||||
// Применяет фильтры выборки (EventType/ActorType/TenantId/ActorId/At-range); без Limit/Offset/сортировки.
|
||||
// query: Базовый запрос.
|
||||
// filter: Фильтр выборки.
|
||||
// Возвращает: Запрос с фильтрами.
|
||||
private static IQueryable<AuditLogEntity> ApplyFilters(IQueryable<AuditLogEntity> query, AuditQueryDto filter)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(filter.EventType))
|
||||
{
|
||||
query = query.Where(a => a.EventType == filter.EventType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.ActorType))
|
||||
{
|
||||
query = query.Where(a => a.ActorType == filter.ActorType);
|
||||
}
|
||||
|
||||
if (filter.TenantId is not null)
|
||||
{
|
||||
query = query.Where(a => a.TenantId == filter.TenantId);
|
||||
}
|
||||
|
||||
if (filter.ActorId is not null)
|
||||
{
|
||||
query = query.Where(a => a.ActorId == filter.ActorId);
|
||||
}
|
||||
|
||||
if (filter.From is not null)
|
||||
{
|
||||
query = query.Where(a => a.At >= filter.From.Value);
|
||||
}
|
||||
|
||||
if (filter.To is not null)
|
||||
{
|
||||
query = query.Where(a => a.At <= filter.To.Value);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
// Клампит размер выборки в 1..MaxQueryLimit (Ruling 4: limit ≤500).
|
||||
// limit: Запрошенный размер.
|
||||
// Возвращает: Клампированное значение.
|
||||
private static int ClampLimit(int limit) => Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit));
|
||||
|
||||
private static AuditRecordDto ToAuditRecordDto(AuditLogEntity entity) =>
|
||||
new(
|
||||
entity.EventType,
|
||||
entity.ActorType,
|
||||
entity.ActorId,
|
||||
entity.TenantId,
|
||||
entity.Ip,
|
||||
entity.DetailJson,
|
||||
entity.At,
|
||||
entity.Id);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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.users и public.sessions.
|
||||
/// </summary>
|
||||
/// <remarks>Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1).</remarks>
|
||||
public sealed class AuthStore(DealDbContext dbContext) : IAuthStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoredUserDto?> FindUserByLoginAsync(string login, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.Users
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(u => u.Login == login, ct);
|
||||
return entity is null ? null : ToStoredUserDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateUserAsync(StoredUserDto user, CancellationToken ct)
|
||||
{
|
||||
dbContext.Users.Add(new UserEntity
|
||||
{
|
||||
Id = user.Id,
|
||||
Login = user.Login,
|
||||
TenantId = user.TenantId,
|
||||
Status = user.Status,
|
||||
PasswordHash = user.PasswordHash,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SessionDto?> FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct)
|
||||
{
|
||||
// Протухшие сессии не возвращаем: с момента истечения они «не существуют»
|
||||
// (физическую очистку выполняет DeleteExpiredSessionsAsync).
|
||||
var entity = await dbContext.Sessions
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(s => s.TokenHash == tokenHash && s.ExpiresAt > DateTimeOffset.UtcNow, ct);
|
||||
return entity is null ? null : ToSessionDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserIdentityDto?> FindUserByIdAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.Users
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(u => u.Id == userId, ct);
|
||||
return entity is null ? null : ToUserIdentityDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<UserIdentityDto>> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
// Порядок по CreatedAt — «первый пользователь тенанта» для impersonation без login (Task 7) детерминирован.
|
||||
var entities = await dbContext.Users
|
||||
.AsNoTracking()
|
||||
.Where(u => u.TenantId == tenantId)
|
||||
.OrderBy(u => u.CreatedAt)
|
||||
.ThenBy(u => u.Login)
|
||||
.Select(u => ToUserIdentityDto(u))
|
||||
.ToListAsync(ct);
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateSessionAsync(SessionDto session, CancellationToken ct)
|
||||
{
|
||||
dbContext.Sessions.Add(new SessionEntity
|
||||
{
|
||||
TokenHash = session.TokenHash,
|
||||
UserId = session.UserId,
|
||||
Login = session.Login,
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ImpersonatedByOperatorId = session.ImpersonatedByOperatorId,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteSessionAsync(string tokenHash, CancellationToken ct)
|
||||
{
|
||||
await dbContext.Sessions
|
||||
.Where(s => s.TokenHash == tokenHash)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteSessionsByUserIdAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
await dbContext.Sessions
|
||||
.Where(s => s.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdatePasswordHashAsync(Guid userId, string passwordHash, CancellationToken ct)
|
||||
{
|
||||
await dbContext.Users
|
||||
.Where(u => u.Id == userId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(u => u.PasswordHash, passwordHash), ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteExpiredSessionsAsync(CancellationToken ct)
|
||||
{
|
||||
await dbContext.Sessions
|
||||
.Where(s => s.ExpiresAt <= DateTimeOffset.UtcNow)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
private static StoredUserDto ToStoredUserDto(UserEntity entity) =>
|
||||
new(entity.Id, entity.Login, entity.TenantId, entity.Status, entity.PasswordHash);
|
||||
|
||||
private static UserIdentityDto ToUserIdentityDto(UserEntity entity) =>
|
||||
new(entity.Id, entity.Login, entity.TenantId, entity.Status);
|
||||
|
||||
private static SessionDto ToSessionDto(SessionEntity entity) =>
|
||||
new(entity.TokenHash, entity.UserId, entity.Login, entity.ExpiresAt, entity.ImpersonatedByOperatorId);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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.invites (Ruling 2 этапа 7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). «expired» здесь
|
||||
/// не вычисляется: ленивое проставление статуса выполняет <see cref="InvitesService"/> при чтении/проверке,
|
||||
/// адаптер лишь меняет статус по коду (<see cref="UpdateStatusAsync"/>). List упорядочен CreatedAt DESC —
|
||||
/// свежие приглашения сверху в операторском списке.
|
||||
/// </remarks>
|
||||
public sealed class InviteStore(DealDbContext dbContext) : IInviteStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task CreateAsync(InviteDto invite, CancellationToken ct)
|
||||
{
|
||||
dbContext.Invites.Add(new InviteEntity
|
||||
{
|
||||
Code = invite.Code,
|
||||
Email = invite.Email,
|
||||
TenantId = invite.TenantId,
|
||||
Status = invite.Status,
|
||||
ExpiresAt = invite.ExpiresAt,
|
||||
ActivatedAt = invite.ActivatedAt,
|
||||
CreatedById = invite.CreatedById,
|
||||
CreatedAt = invite.CreatedAt,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<InviteDto?> GetByCodeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.Invites
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(i => i.Code == code, ct);
|
||||
return entity is null ? null : ToInviteDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<InviteDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
var entities = await dbContext.Invites
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(i => i.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(ToInviteDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateStatusAsync(string code, string status, DateTimeOffset? activatedAt, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.Invites.SingleOrDefaultAsync(i => i.Code == code, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entity.Status = status;
|
||||
entity.ActivatedAt = activatedAt;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> TryActivateAsync(string code, DateTimeOffset activatedAt, CancellationToken ct)
|
||||
{
|
||||
// CAS (Task 6): атомарный условный UPDATE — pending → activated только если статус всё ещё pending,
|
||||
// иначе параллельный отзыв/активация не перезаписываются (ExecuteUpdate выполняется одним оператором SQL).
|
||||
int affected = await dbContext.Invites
|
||||
.Where(i => i.Code == code && i.Status == InviteStatuses.Pending)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(i => i.Status, InviteStatuses.Activated)
|
||||
.SetProperty(i => i.ActivatedAt, activatedAt), ct);
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<InviteDto?> FindActiveByEmailAsync(string email, CancellationToken ct)
|
||||
{
|
||||
// «Активное» = статус pending (зеркало частичного unique-индекса InviteConfiguration: WHERE Status='pending').
|
||||
// Протухшее, но ещё не помеченное pending-приглашение тоже вернётся — переводом в expired занимается сервис.
|
||||
var entity = await dbContext.Invites
|
||||
.AsNoTracking()
|
||||
.Where(i => i.Status == InviteStatuses.Pending && i.Email == email)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return entity is null ? null : ToInviteDto(entity);
|
||||
}
|
||||
|
||||
private static InviteDto ToInviteDto(InviteEntity entity) =>
|
||||
new(
|
||||
entity.Code,
|
||||
entity.Email,
|
||||
entity.TenantId,
|
||||
entity.Status,
|
||||
entity.ExpiresAt,
|
||||
entity.ActivatedAt,
|
||||
entity.CreatedById,
|
||||
entity.CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
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: выделено из общего файла,
|
||||
/// поведение не менялось): чтение списка/поиск/одна карточка, создание из снимка,
|
||||
/// смена колонки/seen, жёсткое удаление, очистка колонки, счётчики.
|
||||
/// </summary>
|
||||
public sealed partial class KanbanStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<CardDto>> ListCardsAsync(CardsQuery query, CancellationToken ct)
|
||||
{
|
||||
IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking();
|
||||
if (query.Col is null)
|
||||
{
|
||||
// Все колонки дашборда, кроме контейнеров-стадий «Выбранных» (этап 9): карточка, взятая
|
||||
// в работу, живёт в том же пространстве «Выбранных», а не на дашборде.
|
||||
queryable = queryable.Where(card => !SelectedStageIds.Contains(card.Col));
|
||||
}
|
||||
else
|
||||
{
|
||||
queryable = queryable.Where(card => card.Col == query.Col);
|
||||
}
|
||||
|
||||
List<CardEntity> entities = await queryable
|
||||
.OrderByDescending(card => card.ReceivedAt)
|
||||
.ToListAsync(ct);
|
||||
return await ToCardDtosAsync(entities, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<CardDto>> SearchCardsAsync(string q, int limit, CancellationToken ct)
|
||||
{
|
||||
string query = q.Trim();
|
||||
if (query.Length == 0)
|
||||
{
|
||||
return Array.Empty<CardDto>();
|
||||
}
|
||||
|
||||
// FTS + LIKE-дополнение одним запросом (Ruling 6): SearchTsv @@ plainto_tsquery('russian') ∪ lower
|
||||
// LIKE по title/summary/source_msg/contact (search L527–529), контейнеры-стадии «Выбранных»
|
||||
// исключены, порядок — ts_rank DESC, ReceivedAt DESC, limit 12. plainto_tsquery со стоп-словами даёт
|
||||
// пустой tsquery — совпадений нет (не ошибка), LIKE ниже всё равно отработает. Всё параметризовано
|
||||
// (никакой конкатенации пользовательского ввода); LIKE-паттерн — как в отсев-поиске PipelineStore.
|
||||
string pattern = $"%{query}%";
|
||||
List<CardEntity> entities = await _dbContext.Cards
|
||||
.FromSqlInterpolated(
|
||||
$"""
|
||||
SELECT * FROM "Cards"
|
||||
WHERE "Col" <> ALL({SelectedStageIds})
|
||||
AND ("SearchTsv" @@ plainto_tsquery('russian', {query})
|
||||
OR lower("Title") LIKE {pattern}
|
||||
OR lower("Summary") LIKE {pattern}
|
||||
OR lower("SourceMsg") LIKE {pattern}
|
||||
OR lower("Contact") LIKE {pattern})
|
||||
ORDER BY ts_rank("SearchTsv", plainto_tsquery('russian', {query})) DESC, "ReceivedAt" DESC
|
||||
LIMIT {limit}
|
||||
""")
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
return await ToCardDtosAsync(entities, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
CardEntity? entity = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(card => card.Id == cardId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<CardDto> cards = await ToCardDtosAsync([entity], ct);
|
||||
return cards[0];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CardDto?> GetCardBySourceAsync(string dialogId, long msgId, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dialogId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
CardEntity? entity = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.SourceDialogId == dialogId && card.SourceMsgId == msgId)
|
||||
.OrderByDescending(card => card.ReceivedAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<CardDto> cards = await ToCardDtosAsync([entity], ct);
|
||||
return cards[0];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct)
|
||||
{
|
||||
// CreatedAt проставляет хранилище (UTC-now) — в snapshot поля нет (см. CardSnapshot).
|
||||
_dbContext.Cards.Add(ToCardEntity(snapshot));
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
|
||||
// Стартовые комментарии (ручное создание): таблица LeadComments, CreatedAt — один «now» вставки.
|
||||
if (snapshot.Comments.Count > 0)
|
||||
{
|
||||
DateTimeOffset commentAt = DateTimeOffset.UtcNow;
|
||||
foreach (CardCommentDto comment in snapshot.Comments)
|
||||
{
|
||||
_dbContext.LeadComments.Add(new LeadCommentEntity
|
||||
{
|
||||
Id = comment.Id,
|
||||
CardId = snapshot.Id,
|
||||
By = comment.By,
|
||||
Text = comment.Text,
|
||||
CreatedAt = commentAt,
|
||||
});
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct)
|
||||
{
|
||||
// PrevCol=null — не менять (автоархив тика prev_col не трогает, Ruling 8): условное обновление
|
||||
// через ExecuteUpdate потребовало бы двух запросов, чтение со слежением проще и точнее.
|
||||
CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
// Карточки нет — переносить нечего (сервис валидирует существование до вызова, Task 7).
|
||||
return;
|
||||
}
|
||||
|
||||
entity.Col = update.Col;
|
||||
entity.IsNew = update.IsNew;
|
||||
if (update.PrevCol is not null)
|
||||
{
|
||||
entity.PrevCol = update.PrevCol;
|
||||
}
|
||||
|
||||
entity.ArchivedAt = update.ArchivedAt;
|
||||
entity.MatchHitsJson = ToJson(update.MatchHits);
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct)
|
||||
{
|
||||
// Полная замена полей классификации одним обновлением (leads.py reclassify_lead L346–367): бюджет без
|
||||
// валюты и отсутствие конверсии очищают поля (BudgetCur/ConvCur = ""), PrevCol/ArchivedAt не трогаются.
|
||||
CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entity.Col = update.Col;
|
||||
entity.IsNew = update.IsNew;
|
||||
entity.IsVacancy = update.IsVacancy;
|
||||
entity.IsVacancyKnown = update.IsVacancyKnown;
|
||||
entity.Title = update.Title;
|
||||
entity.Summary = update.Summary;
|
||||
entity.StackJson = ToJson(update.Stack);
|
||||
entity.BudgetFrom = update.Budget?.From;
|
||||
entity.BudgetTo = update.Budget?.To;
|
||||
entity.BudgetCur = update.Budget?.Cur ?? string.Empty;
|
||||
entity.ConvFrom = update.Converted?.From;
|
||||
entity.ConvTo = update.Converted?.To;
|
||||
entity.ConvCur = update.Converted?.Cur ?? string.Empty;
|
||||
entity.Contact = update.Contact;
|
||||
entity.ContactsJson = ToJson(update.Contacts);
|
||||
entity.MatchHitsJson = ToJson(update.MatchHits);
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateSeenAsync(string? cardId, string? col, CancellationToken ct)
|
||||
{
|
||||
// Три режима leads.py mark_seen L250–256: одна карточка | колонка | все карточки.
|
||||
IQueryable<CardEntity> queryable = _dbContext.Cards;
|
||||
if (cardId is not null)
|
||||
{
|
||||
queryable = queryable.Where(card => card.Id == cardId);
|
||||
}
|
||||
else if (col is not null)
|
||||
{
|
||||
queryable = queryable.Where(card => card.Col == col);
|
||||
}
|
||||
|
||||
await queryable.ExecuteUpdateAsync(setters => setters.SetProperty(card => card.IsNew, false), ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteForeverAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
// Жёсткое удаление карточки (leads.py _hard_delete L225–234): комментарии чистит каскад БД
|
||||
// (FK LeadComments → Cards, Ruling 1); журнал CardMoves и MlOutbox не трогаем. Строки дедупа
|
||||
// карточки (DELETE DedupEntries WHERE LeadId=?) удаляем в той же транзакции (Ruling 3) — иначе
|
||||
// «сирота» заблокирует повторное создание карточки при перечитывании канала.
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
await _dbContext.DedupEntries
|
||||
.Where(entry => entry.LeadId == cardId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await _dbContext.Cards
|
||||
.Where(card => card.Id == cardId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ClearColAsync(string col, CancellationToken ct)
|
||||
{
|
||||
// Полная очистка служебной колонки (только trash|archive — валидирует сервис), leads.py clear_col L237–247.
|
||||
// Дедуп-строки карточек колонки удаляем в той же транзакции (Ruling 3, _hard_delete L229) — очистка
|
||||
// корзины/архива тоже должна снимать «сирот», иначе текст из удалённой карточки не пройдёт дедуп.
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
await _dbContext.DedupEntries
|
||||
.Where(entry => _dbContext.Cards.Any(card => card.Col == col && card.Id == entry.LeadId))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
int cleared = await _dbContext.Cards
|
||||
.Where(card => card.Col == col)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<string, CardColumnCountDto>> CountCardsByColAsync(CancellationToken ct)
|
||||
{
|
||||
// GROUP BY Cards (col, is_new): ключи — только колонки с карточками (leads.py counts L268–279).
|
||||
var rows = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => !SelectedStageIds.Contains(card.Col))
|
||||
.GroupBy(card => card.Col)
|
||||
.Select(group => new { Col = group.Key, Count = group.Count(), NewCount = group.Count(card => card.IsNew) })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var result = new Dictionary<string, CardColumnCountDto>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
result.Add(row.Col, new CardColumnCountDto(row.Count, row.NewCount));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application;
|
||||
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 />
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Контейнеры карточек — partial-часть <see cref="KanbanStore"/> (этап 9, T4): единый реестр
|
||||
/// колонок/стадий/зон на таблице Containers: список/чтение/создание/полное обновление/удаление с
|
||||
/// переносом карточек в «Неразобранное»/перестановка позиций.
|
||||
/// </summary>
|
||||
public sealed partial class KanbanStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ContainerDto>> ListContainersAsync(string? space, CancellationToken ct)
|
||||
{
|
||||
IQueryable<ContainerEntity> queryable = _dbContext.Containers.AsNoTracking();
|
||||
if (space is not null)
|
||||
{
|
||||
queryable = queryable.Where(container => container.Space == space);
|
||||
}
|
||||
|
||||
// Принятые колонки идут первыми, ИИ-предложения — в конец (как ORDER BY suggested, position).
|
||||
List<ContainerEntity> entities = await queryable
|
||||
.OrderBy(container => container.Suggested)
|
||||
.ThenBy(container => container.Position)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(ToContainerDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ContainerDto?> GetContainerAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
ContainerEntity? entity = await _dbContext.Containers
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(container => container.Id == containerId, ct);
|
||||
return entity is null ? null : ToContainerDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateContainerAsync(ContainerDto container, CancellationToken ct)
|
||||
{
|
||||
_dbContext.Containers.Add(ToContainerEntity(container));
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateContainerAsync(ContainerDto container, CancellationToken ct)
|
||||
{
|
||||
// Полное обновление строки (сервис читает Get + применяет ContainerPatchDto): JSON-поля пишутся
|
||||
// целиком, CreatedAt не трогаем — одним UPDATE.
|
||||
await _dbContext.Containers
|
||||
.Where(row => row.Id == container.Id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(row => row.Name, container.Name)
|
||||
.SetProperty(row => row.Description, container.Description)
|
||||
.SetProperty(row => row.Color, container.Color)
|
||||
.SetProperty(row => row.Position, container.Order)
|
||||
.SetProperty(row => row.Collapsed, container.Collapsed)
|
||||
.SetProperty(row => row.Suggested, container.Suggested)
|
||||
.SetProperty(row => row.RulesJson, ToRulesJson(container.Rules))
|
||||
.SetProperty(row => row.Note, container.Note)
|
||||
.SetProperty(row => row.PolicyJson, ToPolicyJson(container.Policy)),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> DeleteContainerAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
// Два изменения разных таблиц — в одной транзакции: либо карточки ушли в «Неразобранное» и контейнер
|
||||
// удалён, либо ничего не изменилось.
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
int moved = await _dbContext.Cards
|
||||
.Where(card => card.Col == containerId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.Col, CardIds.Inbox)
|
||||
.SetProperty(card => card.IsNew, true)
|
||||
.SetProperty(card => card.PrevCol, CardIds.Inbox),
|
||||
ct);
|
||||
await _dbContext.Containers
|
||||
.Where(container => container.Id == containerId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
return moved;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReorderContainersAsync(string space, IReadOnlyList<string> containerIds, CancellationToken ct)
|
||||
{
|
||||
// Позиции 0..N-1 — одна транзакция: при сбое середины порядок не остаётся частично проставленным.
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
for (int i = 0; i < containerIds.Count; i++)
|
||||
{
|
||||
int position = i;
|
||||
string containerId = containerIds[i];
|
||||
await _dbContext.Containers
|
||||
.Where(container => container.Id == containerId && container.Space == space)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(container => container.Position, position), ct);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Операции карточек пространства «Выбранные» — partial-часть <see cref="KanbanStore"/> (этап 9:
|
||||
/// после слияния домена карточка — одна строка таблицы Cards, стадия — её контейнер): список стадий
|
||||
/// (UpdatedAt DESC), правка полей, ссылки/файлы (jsonb-append/filter), перенос по стадии с историей и
|
||||
/// сбросом напоминания, напоминания (set/clear/due/fired/очистка протухших), очистка стадии.
|
||||
/// </summary>
|
||||
public sealed partial class KanbanStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct)
|
||||
{
|
||||
IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking();
|
||||
if (containerId is not null)
|
||||
{
|
||||
queryable = queryable.Where(card => card.Col == containerId);
|
||||
}
|
||||
else
|
||||
{
|
||||
queryable = queryable.Where(card => SelectedStageIds.Contains(card.Col));
|
||||
}
|
||||
|
||||
List<CardEntity> entities = await queryable
|
||||
.OrderByDescending(card => card.UpdatedAt)
|
||||
.ToListAsync(ct);
|
||||
return await ToCardDtosAsync(entities, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> PatchCardAsync(string cardId, CardPatch patch, CancellationToken ct)
|
||||
{
|
||||
// Точечная правка по присутствующим полям патча: null-поле не меняется, JSON-поля заменяются
|
||||
// целиком, в конце — bump UpdatedAt (patch_card projects.py L159–187).
|
||||
CardEntity? entity = await _dbContext.Cards
|
||||
.SingleOrDefaultAsync(card => card.Id == cardId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyPatch(entity, patch);
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (patch.Comments is not null)
|
||||
{
|
||||
await _dbContext.LeadComments
|
||||
.Where(comment => comment.CardId == cardId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
DateTimeOffset commentAt = DateTimeOffset.UtcNow;
|
||||
foreach (CardCommentDto comment in patch.Comments)
|
||||
{
|
||||
_dbContext.LeadComments.Add(new LeadCommentEntity
|
||||
{
|
||||
Id = comment.Id,
|
||||
CardId = cardId,
|
||||
By = comment.By,
|
||||
Text = comment.Text,
|
||||
CreatedAt = commentAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> AddLinkAsync(string cardId, CardLinkDto link, CancellationToken ct)
|
||||
{
|
||||
// Один UPDATE — jsonb-append в конец массива links (add_link L133–143 + bump UpdatedAt).
|
||||
return await ExecuteJsonMutationAsync(
|
||||
$"""
|
||||
UPDATE "Cards"
|
||||
SET "LinksJson" = (COALESCE(NULLIF("LinksJson", '')::jsonb, '[]'::jsonb)
|
||||
|| jsonb_build_array({ToJson(link)}::jsonb))::text,
|
||||
"UpdatedAt" = {DateTimeOffset.UtcNow}
|
||||
WHERE "Id" = {cardId}
|
||||
""",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RemoveLinkAsync(string cardId, string linkId, CancellationToken ct)
|
||||
{
|
||||
// Один UPDATE — jsonb-фильтрация массива по id элемента (remove_link L146–150 + bump UpdatedAt).
|
||||
return await ExecuteJsonMutationAsync(
|
||||
$"""
|
||||
UPDATE "Cards"
|
||||
SET "LinksJson" = COALESCE((
|
||||
SELECT jsonb_agg(item ORDER BY ord)
|
||||
FROM jsonb_array_elements(COALESCE(NULLIF("LinksJson", '')::jsonb, '[]'::jsonb))
|
||||
WITH ORDINALITY AS t(item, ord)
|
||||
WHERE item ->> 'id' <> {linkId}
|
||||
), '[]'::jsonb)::text,
|
||||
"UpdatedAt" = {DateTimeOffset.UtcNow}
|
||||
WHERE "Id" = {cardId}
|
||||
""",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> AddFileAsync(string cardId, CardFileDto file, CancellationToken ct)
|
||||
{
|
||||
// Один UPDATE — jsonb-append в конец массива files (files.py add_file L57–75 + bump UpdatedAt).
|
||||
return await ExecuteJsonMutationAsync(
|
||||
$"""
|
||||
UPDATE "Cards"
|
||||
SET "FilesJson" = (COALESCE(NULLIF("FilesJson", '')::jsonb, '[]'::jsonb)
|
||||
|| jsonb_build_array({ToJson(file)}::jsonb))::text,
|
||||
"UpdatedAt" = {DateTimeOffset.UtcNow}
|
||||
WHERE "Id" = {cardId}
|
||||
""",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RemoveFileAsync(string cardId, string fileId, CancellationToken ct)
|
||||
{
|
||||
// Один UPDATE — jsonb-фильтрация массива files по id записи (files.py remove_file L86–94 + bump).
|
||||
return await ExecuteJsonMutationAsync(
|
||||
$"""
|
||||
UPDATE "Cards"
|
||||
SET "FilesJson" = COALESCE((
|
||||
SELECT jsonb_agg(item ORDER BY ord)
|
||||
FROM jsonb_array_elements(COALESCE(NULLIF("FilesJson", '')::jsonb, '[]'::jsonb))
|
||||
WITH ORDINALITY AS t(item, ord)
|
||||
WHERE item ->> 'id' <> {fileId}
|
||||
), '[]'::jsonb)::text,
|
||||
"UpdatedAt" = {DateTimeOffset.UtcNow}
|
||||
WHERE "Id" = {cardId}
|
||||
""",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> MoveCardStageAsync(
|
||||
string cardId, string containerId, CardHistoryDto historyEntry, long atMs, CancellationToken ct)
|
||||
{
|
||||
// Смена контейнера-стадии: Col=containerId, сброс напоминания, updated_at=atMs, история + запись
|
||||
// (move_stage projects.py L202–216; Ruling 7). Нетрекинговое чтение + ExecuteUpdate.
|
||||
CardEntity? entity = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(card => card.Id == cardId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
List<CardHistoryDto> history = ToJsonList<CardHistoryDto>(entity.HistoryJson).ToList();
|
||||
history.Add(historyEntry);
|
||||
DateTimeOffset at = DateTimeOffset.FromUnixTimeMilliseconds(atMs);
|
||||
|
||||
await _dbContext.Cards
|
||||
.Where(card => card.Id == cardId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.Col, containerId)
|
||||
.SetProperty(card => card.ReminderAt, (DateTimeOffset?)null)
|
||||
.SetProperty(card => card.ReminderFired, false)
|
||||
.SetProperty(card => card.UpdatedAt, at)
|
||||
.SetProperty(card => card.HistoryJson, ToJson(history)),
|
||||
ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetReminderAsync(string cardId, long atMs, CancellationToken ct)
|
||||
{
|
||||
await _dbContext.Cards
|
||||
.Where(card => card.Id == cardId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.ReminderAt, DateTimeOffset.FromUnixTimeMilliseconds(atMs))
|
||||
.SetProperty(card => card.ReminderFired, false)
|
||||
.SetProperty(card => card.UpdatedAt, DateTimeOffset.UtcNow),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ClearReminderAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
await _dbContext.Cards
|
||||
.Where(card => card.Id == cardId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.ReminderAt, (DateTimeOffset?)null)
|
||||
.SetProperty(card => card.ReminderFired, false),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ClearStageAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
// Число удалённых — затронутые строки ExecuteDelete (clear_stage projects.py L223–231). Комментарии
|
||||
// снимает каскад FK LeadComments → Cards.
|
||||
return await _dbContext.Cards
|
||||
.Where(card => card.Col == containerId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
||||
{
|
||||
// Наступившие напоминания hold-карточек в порядке наступления (check_reminders projects.py L270–275).
|
||||
return await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.Col == HoldStage
|
||||
&& card.ReminderAt != null
|
||||
&& !card.ReminderFired
|
||||
&& card.ReminderAt <= now)
|
||||
.OrderBy(card => card.ReminderAt)
|
||||
.Select(card => new CardReminderDueDto(card.Id, card.Title, card.Col))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
|
||||
{
|
||||
if (cardIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _dbContext.Cards
|
||||
.Where(card => cardIds.Contains(card.Id))
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(card => card.ReminderFired, true), ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
||||
{
|
||||
// Протухшие напоминания при выключенной настройке: fired не важен — чистим все (L266–269, Ruling 3).
|
||||
return await _dbContext.Cards
|
||||
.Where(card => card.ReminderAt != null && card.ReminderAt <= now)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.ReminderAt, (DateTimeOffset?)null)
|
||||
.SetProperty(card => card.ReminderFired, false),
|
||||
ct);
|
||||
}
|
||||
|
||||
// Выполняет атомарную JSON-мутацию колонки карточки: один UPDATE, 0 затронутых строк = карточки нет.
|
||||
private async Task<bool> ExecuteJsonMutationAsync(FormattableString sql, CancellationToken ct)
|
||||
{
|
||||
int affected = await _dbContext.Database.ExecuteSqlInterpolatedAsync(sql, ct);
|
||||
return affected == 1;
|
||||
}
|
||||
|
||||
// Накладывает поля патча на сущность: null-поле пропускается (не меняется), JSON-поля — полная замена (patch_card L159–187).
|
||||
private static void ApplyPatch(CardEntity entity, CardPatch patch)
|
||||
{
|
||||
if (patch.Title is not null)
|
||||
{
|
||||
entity.Title = patch.Title;
|
||||
}
|
||||
|
||||
if (patch.Summary is not null)
|
||||
{
|
||||
entity.Summary = patch.Summary;
|
||||
}
|
||||
|
||||
if (patch.Contact is not null)
|
||||
{
|
||||
entity.Contact = patch.Contact;
|
||||
}
|
||||
|
||||
if (patch.TzText is not null)
|
||||
{
|
||||
entity.TzText = patch.TzText;
|
||||
}
|
||||
|
||||
if (patch.Stack is not null)
|
||||
{
|
||||
entity.StackJson = ToJson(patch.Stack);
|
||||
}
|
||||
|
||||
if (patch.Budget is not null)
|
||||
{
|
||||
entity.BudgetFrom = patch.Budget.From;
|
||||
entity.BudgetTo = patch.Budget.To;
|
||||
entity.BudgetCur = patch.Budget.Cur;
|
||||
}
|
||||
|
||||
if (patch.Links is not null)
|
||||
{
|
||||
entity.LinksJson = ToJson(patch.Links);
|
||||
}
|
||||
|
||||
if (patch.Files is not null)
|
||||
{
|
||||
entity.FilesJson = ToJson(patch.Files);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application;
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища обучения ML (Ruling 4, Task 5): очередь MlOutbox + журнал CardMoves схемы тенанта.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Реализация порта <see cref="IMlLearningStore"/> на <see cref="TenantDbContext"/> (эталон
|
||||
/// <c>KanbanStore</c>). Порт потребляет интеграционный адаптер <c>LocalMlClient</c>, чтобы тот
|
||||
/// оставался unit-чистым (план Task 5). Id строки outbox приходят готовыми (Ruling 12); CreatedAt
|
||||
/// проставляется здесь (UTC-now, как в AddMoveAsync). Одиночная запись — SaveChanges; полная очистка
|
||||
/// очереди — ExecuteDelete одним statement'ом (reset_model L122); журнал CardMoves не трогается.
|
||||
/// </remarks>
|
||||
/// <param name="dbContext">Scoped-контекст тенанта запроса (search_path).</param>
|
||||
public sealed class MlLearningStore(TenantDbContext dbContext) : IMlLearningStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountLearningAsync(CancellationToken ct) => dbContext.CardMoves.CountAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountOutboxAsync(CancellationToken ct) => dbContext.MlOutbox.CountAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddOutboxAsync(string id, string text, string label, double delta, CancellationToken ct)
|
||||
{
|
||||
dbContext.MlOutbox.Add(new MlOutboxEntity
|
||||
{
|
||||
Id = id,
|
||||
Text = text,
|
||||
Label = label,
|
||||
Delta = delta,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ClearOutboxAsync(CancellationToken ct) => dbContext.MlOutbox.ExecuteDeleteAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MlOutboxEntryDto>> TakeOutboxBatchAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
// Порция в порядке created_at, без удаления (1:1 выборка python flush_outbox L66–69): удаление —
|
||||
// отдельный шаг после успеха TrainBatch (Ruling 6).
|
||||
return await dbContext.MlOutbox
|
||||
.OrderBy(row => row.CreatedAt)
|
||||
.Take(limit)
|
||||
.Select(row => new MlOutboxEntryDto(row.Id, row.Text, row.Label, row.Delta))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteOutboxAsync(IReadOnlyCollection<string> ids, CancellationToken ct)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Удаление отправленных строк одним statement'ом (аналог DELETE … WHERE id IN python L79).
|
||||
await dbContext.MlOutbox
|
||||
.Where(row => ids.Contains(row.Id))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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.operators и public.operator_sessions (Ruling 1).
|
||||
/// </summary>
|
||||
/// <remarks>Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1).</remarks>
|
||||
public sealed class OperatorAuthStore(DealDbContext dbContext) : IOperatorAuthStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoredOperatorDto?> FindByLoginAsync(string login, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.Operators
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(o => o.Login == login, ct);
|
||||
return entity is null ? null : ToStoredOperatorDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
|
||||
{
|
||||
dbContext.Operators.Add(new OperatorEntity
|
||||
{
|
||||
Id = operatorRecord.Id,
|
||||
Login = operatorRecord.Login,
|
||||
Status = operatorRecord.Status,
|
||||
PasswordHash = operatorRecord.PasswordHash,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OperatorSessionDto?> FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct)
|
||||
{
|
||||
// Протухшие сессии не возвращаем: с момента истечения они «не существуют»
|
||||
// (физическую очистку выполняет DeleteExpiredSessionsAsync).
|
||||
var entity = await dbContext.OperatorSessions
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(s => s.TokenHash == tokenHash && s.ExpiresAt > DateTimeOffset.UtcNow, ct);
|
||||
return entity is null ? null : ToOperatorSessionDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateSessionAsync(OperatorSessionDto session, CancellationToken ct)
|
||||
{
|
||||
dbContext.OperatorSessions.Add(new OperatorSessionEntity
|
||||
{
|
||||
TokenHash = session.TokenHash,
|
||||
OperatorId = session.OperatorId,
|
||||
Login = session.Login,
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteSessionAsync(string tokenHash, CancellationToken ct)
|
||||
{
|
||||
await dbContext.OperatorSessions
|
||||
.Where(s => s.TokenHash == tokenHash)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteExpiredSessionsAsync(CancellationToken ct)
|
||||
{
|
||||
await dbContext.OperatorSessions
|
||||
.Where(s => s.ExpiresAt <= DateTimeOffset.UtcNow)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
private static StoredOperatorDto ToStoredOperatorDto(OperatorEntity entity) =>
|
||||
new(entity.Id, entity.Login, entity.Status, entity.PasswordHash);
|
||||
|
||||
private static OperatorSessionDto ToOperatorSessionDto(OperatorSessionEntity entity) =>
|
||||
new(entity.TokenHash, entity.OperatorId, entity.Login, entity.ExpiresAt);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Pipeline.Application;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища пайплайна (Rulings 1/10): таблицы QueueItems/RejectedItems/DedupEntries схемы тенанта.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Маппинг DTO ↔ строк выполняется вручную (эталон SettingsStore.cs/KanbanStore.cs): порт модуля не видит
|
||||
/// EF-сущности; подписи stageLabel/sourceLabel считаются словарями <see cref="PipelineRejectConstants"/>
|
||||
/// (processing.stage_label/source_label L40–45). JSON-полей в таблицах нет (Ruling 1(а)) — все колонки
|
||||
/// плоские; SearchTsv (tsvector) — вычисляемая STORED-колонка БД (Ruling 6), адаптер читает её в поиске.
|
||||
/// Времена — timestamptz (DateTimeOffset); наружу строки очереди/отсева отдают epoch-ms
|
||||
/// (ToUnixTimeMilliseconds), обратно — FromUnixTimeMilliseconds. Чтения — AsNoTracking; сортировки
|
||||
/// (CreatedAt ASC у очереди, RejectedAt DESC у отсева) и лимиты — здесь, как требует порт.
|
||||
/// Id строк очереди (<c>p_</c>) генерирует модуль (Ruling 2); id отсева — детерминированный
|
||||
/// <c>r_<dialog>_<msgId></c> из RejectRecord либо <c>r_</c>+hex (общий PrefixId модуля Kanban),
|
||||
/// как processing.record L77. Upsert/claim на ON CONFLICT и FTS-поиск с plainto_tsquery требуют raw SQL —
|
||||
/// ExecuteSqlInterpolatedAsync/FromSqlInterpolated. Транзакции: все методы — одиночные statement'ы
|
||||
/// (SaveChanges/ExecuteUpdate/ExecuteDelete/ExecuteSql), явных транзакций не нужно; чистку DedupEntries
|
||||
/// при удалении карточки выполняет KanbanStore тем же TenantDbContext (Ruling 3) — циклов модулей нет.
|
||||
/// </remarks>
|
||||
public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
|
||||
{
|
||||
// ── Очередь (QueueItems) ───────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsDuplicateAsync(string dialogId, long? msgId, CancellationToken ct)
|
||||
{
|
||||
// Дубль-гвард приёма (Ruling 2, enqueue L70–77): msgId null — проверять нечего.
|
||||
return msgId is null
|
||||
? Task.FromResult(false)
|
||||
: dbContext.QueueItems.AnyAsync(item => item.DialogId == dialogId && item.MsgId == msgId, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddAsync(QueueItemDto item, CancellationToken ct)
|
||||
{
|
||||
dbContext.QueueItems.Add(ToQueueItemEntity(item));
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<QueueItemDto>> ListAsync(string? status, int limit, CancellationToken ct)
|
||||
{
|
||||
IQueryable<QueueItemEntity> queryable = dbContext.QueueItems.AsNoTracking();
|
||||
if (status is not null)
|
||||
{
|
||||
queryable = queryable.Where(item => item.Status == status);
|
||||
}
|
||||
|
||||
List<QueueItemEntity> entities = await queryable
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(ToQueueItemDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountByStatusAsync(string status, CancellationToken ct)
|
||||
=> dbContext.QueueItems.CountAsync(item => item.Status == status, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetStatusAsync(string id, string status, CancellationToken ct)
|
||||
{
|
||||
// Смена статуса строки очереди (new → filtered в воркере): UpdatedAt = UTC-now (порт).
|
||||
await dbContext.QueueItems
|
||||
.Where(item => item.Id == id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(item => item.Status, status)
|
||||
.SetProperty(item => item.UpdatedAt, DateTimeOffset.UtcNow),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveAsync(string id, CancellationToken ct)
|
||||
{
|
||||
// Безвозвратное удаление строки очереди на любом этапе отсева (pipeline.py _drop_row L809–813).
|
||||
await dbContext.QueueItems
|
||||
.Where(item => item.Id == id)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
// ── Отсев (RejectedItems) ──────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertAsync(RejectRecord record, CancellationToken ct)
|
||||
{
|
||||
// Пустой/пробельный текст — no-op (processing.record L73–74).
|
||||
if (string.IsNullOrWhiteSpace(record.Text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected);
|
||||
DateTimeOffset rejectedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Upsert по детерминированному id: повторное отбрасывание того же сообщения (dialog+msgId)
|
||||
// обновляет запись, а не копит дубликаты (processing.record L79–101). SearchTsv — вычисляемая
|
||||
// колонка (Ruling 6), в INSERT не входит; Returned/ReturnReason пишем явно false/'' (дефолтов БД
|
||||
// нет — прототип полагался на дефолты SQLite). ON CONFLICT сбрасывает только поля прототипа:
|
||||
// аудит возврата (returned/returnedAt/returnReason) при повторном отсеве переживает.
|
||||
await dbContext.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"""
|
||||
INSERT INTO "RejectedItems" ("Id", "DialogId", "MsgId", "ChannelName", "ChannelHandle", "ChannelHue", "Text", "Stage", "Reason", "Kw", "Source", "MsgAt", "RejectedAt", "Returned", "ReturnReason")
|
||||
VALUES ({id}, {record.DialogId}, {record.MsgId}, {record.ChannelName}, {record.ChannelHandle}, {record.ChannelHue}, {record.Text}, {record.Stage}, {record.Reason}, {record.Kw}, {record.Source}, {FromEpochMs(record.MsgAtMs)}, {rejectedAt}, false, {string.Empty})
|
||||
ON CONFLICT ("Id") DO UPDATE SET
|
||||
"Text" = excluded."Text",
|
||||
"ChannelName" = excluded."ChannelName",
|
||||
"ChannelHandle" = excluded."ChannelHandle",
|
||||
"ChannelHue" = excluded."ChannelHue",
|
||||
"Stage" = excluded."Stage",
|
||||
"Reason" = excluded."Reason",
|
||||
"Kw" = excluded."Kw",
|
||||
"Source" = excluded."Source",
|
||||
"MsgAt" = excluded."MsgAt",
|
||||
"RejectedAt" = excluded."RejectedAt"
|
||||
""",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RejectedItemDto>> ListPageAsync(int offset, int limit, CancellationToken ct)
|
||||
{
|
||||
List<RejectedItemEntity> entities = await dbContext.RejectedItems
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(item => item.RejectedAt)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(ToRejectedItemDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RejectedItemDto>> SearchAsync(string q, int limitFts, int limitLike, CancellationToken ct)
|
||||
{
|
||||
string query = q.Trim();
|
||||
if (query.Length == 0)
|
||||
{
|
||||
return Array.Empty<RejectedItemDto>();
|
||||
}
|
||||
|
||||
// FTS-кандидаты: SearchTsv @@ plainto_tsquery('russian', q), релевантность ts_rank DESC (Ruling 6).
|
||||
// plainto_tsquery со стоп-словами даёт пустой tsquery — совпадений нет (не ошибка), LIKE ниже всё
|
||||
// равно отработает (как связка fts_svc + like в processing.list_rejected L252–270).
|
||||
List<RejectedItemEntity> fts = await dbContext.RejectedItems
|
||||
.FromSqlInterpolated(
|
||||
$"""
|
||||
SELECT * FROM "RejectedItems"
|
||||
WHERE "SearchTsv" @@ plainto_tsquery('russian', {query})
|
||||
ORDER BY ts_rank("SearchTsv", plainto_tsquery('russian', {query})) DESC, "RejectedAt" DESC
|
||||
LIMIT {limitFts}
|
||||
""")
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
|
||||
var rows = fts.Select(ToRejectedItemDto).ToList();
|
||||
var seen = new HashSet<string>(fts.Select(item => item.Id), StringComparer.Ordinal);
|
||||
|
||||
// LIKE-дополнение по lower(text)/reason/kw/ch_name (processing L259–265): свежие записи после
|
||||
// последнего перестроения вектора и слова, которых нет в словаре морфологии. limitLike — как
|
||||
// limit*2 прототипа (сверху ограничивает сервис).
|
||||
string pattern = $"%{query}%";
|
||||
List<RejectedItemEntity> like = await dbContext.RejectedItems
|
||||
.AsNoTracking()
|
||||
.Where(item => EF.Functions.Like(item.Text.ToLower(), pattern)
|
||||
|| EF.Functions.Like(item.Reason.ToLower(), pattern)
|
||||
|| EF.Functions.Like(item.Kw.ToLower(), pattern)
|
||||
|| EF.Functions.Like(item.ChannelName.ToLower(), pattern))
|
||||
.OrderByDescending(item => item.RejectedAt)
|
||||
.Take(limitLike)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (RejectedItemEntity entity in like)
|
||||
{
|
||||
if (seen.Add(entity.Id))
|
||||
{
|
||||
rows.Add(ToRejectedItemDto(entity));
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountAsync(CancellationToken ct) => dbContext.RejectedItems.CountAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RejectedItemDto?> GetAsync(string id, CancellationToken ct)
|
||||
{
|
||||
RejectedItemEntity? entity = await dbContext.RejectedItems
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.Id == id, ct);
|
||||
return entity is null ? null : ToRejectedItemDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(string id, CancellationToken ct)
|
||||
{
|
||||
// Удаление одной записи отсева безвозвратно (processing.delete_one L196–198).
|
||||
await dbContext.RejectedItems
|
||||
.Where(item => item.Id == id)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ClearAsync(CancellationToken ct)
|
||||
{
|
||||
// Полная очистка отсева (processing.clear_all L120–125); возврат — сколько удалено.
|
||||
return await dbContext.RejectedItems.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> PurgeExpiredAsync(DateTimeOffset olderThan, CancellationToken ct)
|
||||
{
|
||||
// Автоочистка: записи с RejectedAt старше срока хранения (processing.purge_expired L104–117).
|
||||
return await dbContext.RejectedItems
|
||||
.Where(item => item.RejectedAt < olderThan)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task MarkReturnedAsync(string id, string reason, DateTimeOffset returnedAt, CancellationToken ct)
|
||||
{
|
||||
// Возврат из отсева: аудит-поля записи, строка не удаляется (return_to_queue L156–159).
|
||||
await dbContext.RejectedItems
|
||||
.Where(item => item.Id == id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(item => item.Returned, true)
|
||||
.SetProperty(item => item.ReturnedAt, returnedAt)
|
||||
.SetProperty(item => item.ReturnReason, reason),
|
||||
ct);
|
||||
}
|
||||
|
||||
// ── Дедуп (DedupEntries) ──────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsAsync(string hash, CancellationToken ct)
|
||||
=> dbContext.DedupEntries.AnyAsync(entry => entry.Hash == hash, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ClaimAsync(string hash, CancellationToken ct)
|
||||
{
|
||||
// Заявка хэша за обрабатываемым сообщением: LeadId=null (Ruling 8, L947–949). ON CONFLICT DO
|
||||
// NOTHING — параллельные дубли одного текста не проходят (сырой SQL: EF-вставка упала бы на PK).
|
||||
// Число вставленных строк — результат: 1 = заявка за этим вызовом, 0 = хэш уже заявлен другим
|
||||
// проходом pump (воркер не создаёт вторую карточку, Ruling 8).
|
||||
int inserted = await dbContext.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"""
|
||||
INSERT INTO "DedupEntries" ("Hash", "LeadId", "CreatedAt")
|
||||
VALUES ({hash}, NULL, {DateTimeOffset.UtcNow})
|
||||
ON CONFLICT ("Hash") DO NOTHING
|
||||
""",
|
||||
ct);
|
||||
return inserted == 1;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteClaimAsync(string hash, CancellationToken ct)
|
||||
{
|
||||
// Снятие незанятой заявки (pipeline.py _drop_row L810–812): строки, уже связанные с карточкой
|
||||
// (LeadId IS NOT NULL), не трогаем — иначе повторный текст прошёл бы дедуп после отсева.
|
||||
await dbContext.DedupEntries
|
||||
.Where(entry => entry.Hash == hash && entry.LeadId == null)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task LinkAsync(string hash, string cardId, CancellationToken ct)
|
||||
{
|
||||
// Связь заявки с созданной карточкой (Ruling 4, порядок AddCard → LinkDedup L512–513).
|
||||
await dbContext.DedupEntries
|
||||
.Where(entry => entry.Hash == hash)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(entry => entry.LeadId, cardId), ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteByCardAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
// Чистка «мягких» ссылок при жёстком удалении карточки (Ruling 3). В KanbanStore-удалениях
|
||||
// дублируется тем же DELETE (см. KanbanStore.DeleteForeverAsync/PurgeAsync/ClearColAsync) —
|
||||
// чтобы Kanban-адаптер не зависел от порта Pipeline; этот метод — для потребителей модуля.
|
||||
await dbContext.DedupEntries
|
||||
.Where(entry => entry.LeadId == cardId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
// ── Маппинг (вручную: порт не видит EF-сущности) ───────────────────────
|
||||
|
||||
// Строка QueueItems → DTO очереди (времена — epoch-ms наружу, Ruling 1).
|
||||
private static QueueItemDto ToQueueItemDto(QueueItemEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
DialogId = entity.DialogId,
|
||||
MsgId = entity.MsgId,
|
||||
Text = entity.Text,
|
||||
Status = entity.Status,
|
||||
Channel = new PipelineChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue),
|
||||
MsgAtMs = entity.MsgAt.ToUnixTimeMilliseconds(),
|
||||
QueuedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(),
|
||||
Force = entity.Force,
|
||||
};
|
||||
|
||||
// DTO очереди → строка QueueItems (CreatedAt = UpdatedAt = QueuedAtMs, задаёт модуль).
|
||||
private static QueueItemEntity ToQueueItemEntity(QueueItemDto item)
|
||||
{
|
||||
DateTimeOffset queuedAt = FromEpochMs(item.QueuedAtMs);
|
||||
return new QueueItemEntity
|
||||
{
|
||||
Id = item.Id,
|
||||
DialogId = item.DialogId,
|
||||
MsgId = item.MsgId,
|
||||
Text = item.Text,
|
||||
Status = item.Status,
|
||||
ChannelName = item.Channel.Name,
|
||||
ChannelHandle = item.Channel.Handle,
|
||||
ChannelHue = item.Channel.Hue,
|
||||
MsgAt = FromEpochMs(item.MsgAtMs),
|
||||
CreatedAt = queuedAt,
|
||||
UpdatedAt = queuedAt,
|
||||
Force = item.Force,
|
||||
};
|
||||
}
|
||||
|
||||
// Строка RejectedItems → DTO отсева (подписи этапа/источника — словари модуля Pipeline).
|
||||
private static RejectedItemDto ToRejectedItemDto(RejectedItemEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
DialogId = entity.DialogId,
|
||||
MsgId = entity.MsgId,
|
||||
Text = entity.Text,
|
||||
Stage = entity.Stage,
|
||||
StageLabel = PipelineRejectConstants.StageLabel(entity.Stage),
|
||||
Reason = entity.Reason,
|
||||
Kw = entity.Kw,
|
||||
Source = entity.Source,
|
||||
SourceLabel = PipelineRejectConstants.SourceLabel(entity.Source),
|
||||
Channel = new PipelineChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue),
|
||||
MsgAtMs = entity.MsgAt.ToUnixTimeMilliseconds(),
|
||||
RejectedAtMs = entity.RejectedAt.ToUnixTimeMilliseconds(),
|
||||
Returned = entity.Returned,
|
||||
ReturnedAtMs = entity.ReturnedAt?.ToUnixTimeMilliseconds(),
|
||||
ReturnReason = entity.ReturnReason,
|
||||
};
|
||||
|
||||
// epoch-ms → DateTimeOffset (UTC-момент для timestamptz-колонки).
|
||||
private static DateTimeOffset FromEpochMs(long epochMs) => DateTimeOffset.FromUnixTimeMilliseconds(epochMs);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер распределённого счётчика фиксированного окна: таблица public.rate_limit_counters
|
||||
/// (этап 12, пакет B).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Атомарность инкремента на Postgres обеспечивает один <c>INSERT … ON CONFLICT DO UPDATE … RETURNING</c>
|
||||
/// (гонки параллельных запросов одного ключа не теряют счёт) — именно ради этого хранилище вынесено из
|
||||
/// памяти процесса. InMemory-провайдер (юнит-тесты) raw SQL не исполняет: тот же результат даёт
|
||||
/// read-modify-write EF в одном SaveChanges (эталон TenantLimitStore). Смена WindowStart сбрасывает
|
||||
/// счётчик — семантика фиксированного окна. Уборка устаревших строк — <see cref="DeleteExpiredAsync"/>
|
||||
/// (фоновый цикл); на Npgsql удаление — ExecuteDelete, на InMemory — выгрузка и RemoveRange.
|
||||
/// </remarks>
|
||||
public sealed class RateLimitCounterStore(DealDbContext dbContext) : IRateLimitCounterStore
|
||||
{
|
||||
// Атомарный upsert счётчика окна на Postgres (INSERT … ON CONFLICT … RETURNING).
|
||||
// Выполняется raw-командой (а не SqlQuery): EF запрещает композицию по не-SELECT SQL, а RETURNING
|
||||
// из INSERT не является SELECT-запросом. Одиночный INSERT … ON CONFLICT атомарен на уровне БД —
|
||||
// параллельные инкременты одного ключа не теряются.
|
||||
private const string NpgsqlIncrementSql = """
|
||||
INSERT INTO public.rate_limit_counters ("Key", "WindowStart", "ExpiresAt", "Count")
|
||||
VALUES (@key, @windowStart, @windowEnd, @amount)
|
||||
ON CONFLICT ("Key") DO UPDATE
|
||||
SET "Count" = CASE
|
||||
WHEN public.rate_limit_counters."WindowStart" = EXCLUDED."WindowStart"
|
||||
THEN public.rate_limit_counters."Count" + EXCLUDED."Count"
|
||||
ELSE EXCLUDED."Count"
|
||||
END,
|
||||
"WindowStart" = EXCLUDED."WindowStart",
|
||||
"ExpiresAt" = EXCLUDED."ExpiresAt"
|
||||
RETURNING "Count";
|
||||
""";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> IncrementAsync(
|
||||
string key, DateTimeOffset windowStart, DateTimeOffset windowEnd, int amount, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(amount);
|
||||
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
return await IncrementNpgsqlAsync(key, windowStart, windowEnd, amount, ct);
|
||||
}
|
||||
|
||||
// InMemory-провайдер (тесты): тот же результат read-modify-write отслеживаемой строки.
|
||||
RateLimitCounterEntity? entry = await dbContext.RateLimitCounters
|
||||
.SingleOrDefaultAsync(x => x.Key == key, ct);
|
||||
if (entry is null)
|
||||
{
|
||||
entry = new RateLimitCounterEntity
|
||||
{
|
||||
Key = key,
|
||||
WindowStart = windowStart,
|
||||
ExpiresAt = windowEnd,
|
||||
Count = amount,
|
||||
};
|
||||
dbContext.RateLimitCounters.Add(entry);
|
||||
}
|
||||
else if (entry.WindowStart == windowStart)
|
||||
{
|
||||
entry.Count += amount;
|
||||
entry.ExpiresAt = windowEnd;
|
||||
}
|
||||
else
|
||||
{
|
||||
entry.WindowStart = windowStart;
|
||||
entry.ExpiresAt = windowEnd;
|
||||
entry.Count = amount;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
return entry.Count;
|
||||
}
|
||||
|
||||
// Атомарный инкремент окна raw-командой на Postgres (INSERT … ON CONFLICT … RETURNING).
|
||||
// key: Ключ счётчика.
|
||||
// windowStart: Начало окна (UTC).
|
||||
// windowEnd: Конец окна (UTC).
|
||||
// amount: Величина приращения.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Значение счётчика после операции.
|
||||
private async Task<int> IncrementNpgsqlAsync(
|
||||
string key, DateTimeOffset windowStart, DateTimeOffset windowEnd, int amount, CancellationToken ct)
|
||||
{
|
||||
DbConnection connection = dbContext.Database.GetDbConnection();
|
||||
bool openedHere = connection.State != ConnectionState.Open;
|
||||
if (openedHere)
|
||||
{
|
||||
await connection.OpenAsync(ct);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = NpgsqlIncrementSql;
|
||||
AddParameter(command, "@key", key);
|
||||
AddParameter(command, "@windowStart", windowStart);
|
||||
AddParameter(command, "@windowEnd", windowEnd);
|
||||
AddParameter(command, "@amount", amount);
|
||||
object? result = await command.ExecuteScalarAsync(ct);
|
||||
return Convert.ToInt32(result, CultureInfo.InvariantCulture);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (openedHere)
|
||||
{
|
||||
await connection.CloseAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляет параметр команды (провайдер-независимо, через DbParameter).
|
||||
// command: Команда upsert.
|
||||
// name: Имя параметра (с префиксом @).
|
||||
// value: Значение.
|
||||
private static void AddParameter(DbCommand command, string name, object value)
|
||||
{
|
||||
DbParameter parameter = command.CreateParameter();
|
||||
parameter.ParameterName = name;
|
||||
parameter.Value = value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> GetCountAsync(string key, DateTimeOffset windowStart, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
return await dbContext.Database
|
||||
.SqlQuery<int>($"""
|
||||
SELECT "Count" AS "Value" FROM public.rate_limit_counters
|
||||
WHERE "Key" = {key} AND "WindowStart" = {windowStart}
|
||||
""")
|
||||
.SingleOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
RateLimitCounterEntity? entry = await dbContext.RateLimitCounters
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(x => x.Key == key, ct);
|
||||
return entry is not null && entry.WindowStart == windowStart ? entry.Count : 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ResetAsync(string key, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
await dbContext.RateLimitCounters.Where(x => x.Key == key).ExecuteDeleteAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
RateLimitCounterEntity? entry = await dbContext.RateLimitCounters
|
||||
.SingleOrDefaultAsync(x => x.Key == key, ct);
|
||||
if (entry is not null)
|
||||
{
|
||||
dbContext.RateLimitCounters.Remove(entry);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> DeleteExpiredAsync(DateTimeOffset now, CancellationToken ct)
|
||||
{
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
return await dbContext.RateLimitCounters.Where(x => x.ExpiresAt < now).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
List<RateLimitCounterEntity> expired = await dbContext.RateLimitCounters
|
||||
.Where(x => x.ExpiresAt < now)
|
||||
.ToListAsync(ct);
|
||||
if (expired.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
dbContext.RateLimitCounters.RemoveRange(expired);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
return expired.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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-хранилища настроек тенанта: таблица settings (Ruling 1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Маппинг 1:1 со строкой таблицы: key/value_json/updated_at (сущность TenantSettingEntity,
|
||||
/// бессхемная модель TenantDbContext — таблица живёт в схеме тенанта через search_path).
|
||||
/// Порт оперирует JSON-строками (сериализацию выполняет модуль Settings), поэтому адаптер
|
||||
/// хранит value_json как текст без интерпретации. Маппинг DTO ↔ сущности выполняется вручную:
|
||||
/// порт модуля не видит EF-сущности. <see cref="SetAsync"/> пишет updated_at = UTC-now.
|
||||
/// </remarks>
|
||||
public sealed class SettingsStore(TenantDbContext dbContext) : ISettingsStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SettingValue?> GetAsync(string key, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.Settings
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(s => s.Key == key, ct);
|
||||
return entity is null ? null : ToSettingValue(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyCollection<SettingValue>> GetAllAsync(CancellationToken ct)
|
||||
{
|
||||
var entities = await dbContext.Settings
|
||||
.AsNoTracking()
|
||||
.OrderBy(s => s.Key)
|
||||
.Select(s => ToSettingValue(s))
|
||||
.ToListAsync(ct);
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetAsync(string key, string valueJson, CancellationToken ct)
|
||||
{
|
||||
// Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется.
|
||||
TenantSettingEntity? entity = await dbContext.Settings
|
||||
.SingleOrDefaultAsync(s => s.Key == key, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
dbContext.Settings.Add(new TenantSettingEntity
|
||||
{
|
||||
Key = key,
|
||||
ValueJson = valueJson,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.ValueJson = valueJson;
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveAsync(string key, CancellationToken ct)
|
||||
{
|
||||
// ExecuteDelete удаляет существующую строку; при отсутствии ключа — no-op.
|
||||
await dbContext.Settings
|
||||
.Where(s => s.Key == key)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
private static SettingValue ToSettingValue(TenantSettingEntity entity) =>
|
||||
new(entity.Key, entity.ValueJson, entity.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Telegram.Application;
|
||||
using Deal.Modules.Telegram.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища каталога диалогов: таблицы Dialogs/TgMessages схемы тенанта (Ruling 7, Task 13).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Реализация порта <see cref="ITelegramStore"/> на <see cref="TenantDbContext"/> (эталон KanbanStore/
|
||||
/// PipelineStore). Семантика 1:1 с python telegram.py/db.py: upsert каталога (ON CONFLICT DO UPDATE метаданных,
|
||||
/// монитор только у новых — _persist_dialogs L484–490), удаление отсутствующих (L491–500), список «monitor DESC,
|
||||
/// name» (L522), UPDATE monitor/backfilled, превью INSERT OR IGNORE (L602–605). Batch-операции — ExecuteUpdate
|
||||
/// одним statement'ом; синхронизация каталога — один SaveChanges (порядок как python: upsert → delete).
|
||||
/// </remarks>
|
||||
/// <param name="dbContext">Scoped-контекст тенанта запроса (search_path).</param>
|
||||
public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SyncFromTelegramAsync(
|
||||
IReadOnlyCollection<TelegramDialogEntryDto> entries, bool autoMonitorNew, CancellationToken ct)
|
||||
{
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
// python L477–478: пустой каталог — возврат 0 без записи в БД.
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Существующие строки каталога — обновляем метаданные, монитор пользователя не трогаем (python L487–489).
|
||||
string[] ids = entries.Select(entry => entry.Id).ToArray();
|
||||
Dictionary<string, DialogEntity> existing = await dbContext.Dialogs
|
||||
.Where(dialog => ids.Contains(dialog.Id))
|
||||
.ToDictionaryAsync(dialog => dialog.Id, ct);
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
foreach (TelegramDialogEntryDto entry in entries)
|
||||
{
|
||||
if (existing.TryGetValue(entry.Id, out DialogEntity? row))
|
||||
{
|
||||
row.Name = entry.Name;
|
||||
row.Handle = entry.Handle;
|
||||
row.Kind = entry.Kind;
|
||||
row.Hue = entry.Hue;
|
||||
row.UpdatedAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Новый диалог: монитор — по настройке autoMonitorNew (python L489), остальное — дефолты строки.
|
||||
dbContext.Dialogs.Add(new DialogEntity
|
||||
{
|
||||
Id = entry.Id,
|
||||
Name = entry.Name,
|
||||
Handle = entry.Handle,
|
||||
Kind = entry.Kind,
|
||||
Hue = entry.Hue,
|
||||
Monitor = autoMonitorNew,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Диалоги, которых больше нет в каталоге Telegram (вышел/удалил), удаляются (python L491–500).
|
||||
List<DialogEntity> stale = await dbContext.Dialogs
|
||||
.Where(dialog => !ids.Contains(dialog.Id))
|
||||
.ToListAsync(ct);
|
||||
dbContext.Dialogs.RemoveRange(stale);
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
return entries.Count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TelegramDialogDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
// Список вкладки «Каналы»: включённые первыми, далее по имени (python list_dialogs L522).
|
||||
List<DialogEntity> rows = await dbContext.Dialogs
|
||||
.OrderByDescending(dialog => dialog.Monitor)
|
||||
.ThenBy(dialog => dialog.Name)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(ToDialogDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyCollection<string>> ListMonitoredIdsAsync(CancellationToken ct)
|
||||
{
|
||||
return await dbContext.Dialogs
|
||||
.Where(dialog => dialog.Monitor)
|
||||
.OrderBy(dialog => dialog.Id)
|
||||
.Select(dialog => dialog.Id)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetMonitorAsync(string dialogId, bool enabled, CancellationToken ct)
|
||||
{
|
||||
return dbContext.Dialogs
|
||||
.Where(dialog => dialog.Id == dialogId)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(dialog => dialog.Monitor, enabled)
|
||||
.SetProperty(dialog => dialog.UpdatedAt, DateTimeOffset.UtcNow),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SetMonitorAllAsync(bool enabled, CancellationToken ct)
|
||||
{
|
||||
int count = await dbContext.Dialogs.CountAsync(ct);
|
||||
await dbContext.Dialogs.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(dialog => dialog.Monitor, enabled)
|
||||
.SetProperty(dialog => dialog.UpdatedAt, DateTimeOffset.UtcNow),
|
||||
ct);
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool?> GetBackfilledAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
return await dbContext.Dialogs
|
||||
.Where(dialog => dialog.Id == dialogId)
|
||||
.Select(dialog => (bool?)dialog.Backfilled)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyCollection<string>> ListNotBackfilledIdsAsync(CancellationToken ct)
|
||||
{
|
||||
return await dbContext.Dialogs
|
||||
.Where(dialog => !dialog.Backfilled)
|
||||
.OrderBy(dialog => dialog.Id)
|
||||
.Select(dialog => dialog.Id)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetBackfilledAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
// python backfill_dialog L387: только флаг, updated_at не трогаем (разбор — служебная операция).
|
||||
return dbContext.Dialogs
|
||||
.Where(dialog => dialog.Id == dialogId)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(dialog => dialog.Backfilled, true), ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertDiscoveredMonitoredAsync(
|
||||
string dialogId, string name, string handle, string kind, string hue, CancellationToken ct)
|
||||
{
|
||||
// 1:1 python add_dialog_monitored L850–873 (INSERT/ON CONFLICT DO UPDATE): строка каталога после
|
||||
// discovery-вступления — метаданные источника, monitor=TRUE, backfilled=FALSE (разбор подхватит
|
||||
// первый фоновый Backfill). Значения нормализованы вызывающим (DialogsService).
|
||||
DialogEntity? existing = await dbContext.Dialogs.FirstOrDefaultAsync(dialog => dialog.Id == dialogId, ct);
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
if (existing is null)
|
||||
{
|
||||
dbContext.Dialogs.Add(new DialogEntity
|
||||
{
|
||||
Id = dialogId,
|
||||
Name = name,
|
||||
Handle = handle,
|
||||
Kind = kind,
|
||||
Hue = hue,
|
||||
Monitor = true,
|
||||
Backfilled = false,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Name = name;
|
||||
existing.Handle = handle;
|
||||
existing.Kind = kind;
|
||||
existing.Hue = hue;
|
||||
existing.Monitor = true;
|
||||
existing.Backfilled = false;
|
||||
existing.UpdatedAt = now;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SavePreviewAsync(
|
||||
string messageId, string dialogId, string text, DateTimeOffset msgAt, CancellationToken ct)
|
||||
{
|
||||
bool exists = await dbContext.TgMessages.AnyAsync(message => message.Id == messageId, ct);
|
||||
if (exists)
|
||||
{
|
||||
// INSERT OR IGNORE python L602–605: первое вхождение сообщения не перезаписывается.
|
||||
return;
|
||||
}
|
||||
|
||||
dbContext.TgMessages.Add(new TgMessageEntity
|
||||
{
|
||||
Id = messageId,
|
||||
DialogId = dialogId,
|
||||
Text = text,
|
||||
MsgAt = msgAt,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task TouchDialogLastAsync(string dialogId, string text, DateTimeOffset at, CancellationToken ct)
|
||||
{
|
||||
// python _on_message L270–273: last_text/last_at/updated_at одним моментом приёма.
|
||||
return dbContext.Dialogs
|
||||
.Where(dialog => dialog.Id == dialogId)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(dialog => dialog.LastText, text)
|
||||
.SetProperty(dialog => dialog.LastAt, at)
|
||||
.SetProperty(dialog => dialog.UpdatedAt, at),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TelegramMessageDto>> ListMessagesAsync(string dialogId, int limit, CancellationToken ct)
|
||||
{
|
||||
List<TgMessageEntity> rows = await dbContext.TgMessages
|
||||
.Where(message => message.DialogId == dialogId)
|
||||
.OrderByDescending(message => message.MsgAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(row => new TelegramMessageDto(
|
||||
row.Id, row.Text, row.MsgAt.ToUnixTimeMilliseconds(), row.LeadId is not null)).ToList();
|
||||
}
|
||||
|
||||
// Маппит строку каталога в DTO списка каналов (list_dialogs L523–533).
|
||||
// row: Строка Dialogs.
|
||||
// Возвращает: Форма §4.8: type = kind (EN-канон), on = monitor, last {text, time}.
|
||||
private static TelegramDialogDto ToDialogDto(DialogEntity row)
|
||||
{
|
||||
long? lastAtMs = row.LastAt?.ToUnixTimeMilliseconds();
|
||||
TelegramDialogLastDto? last = lastAtMs is null ? null : new TelegramDialogLastDto(row.LastText, lastAtMs);
|
||||
return new TelegramDialogDto(row.Id, row.Name, row.Handle, row.Kind, row.Hue, row.Monitor, last);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
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.tenant_limits (Ruling 3 этапа 7, Task 8).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). Строка заводится
|
||||
/// лениво с дефолт-бюджетом адаптера (конфигурация/env <c>DEAL_DEFAULT_AI_BUDGET</c>, фолбэк — константа
|
||||
/// TokenBudgetDefaults) при первом чтении/списании — так лимит-поля закрыты на всех путях чтения (список
|
||||
/// тенантов Task 7/10, recorder Task 8, гейт Task 9) без eager-создания в join/создании тенанта. Reset периода —
|
||||
/// ленивый (<see cref="TokenBudgetService"/>, Ruling 3): применяется при чтении/записи, отдельного фонового
|
||||
/// цикла нет. Списание — read-modify-write отслеживаемой строки в одном SaveChanges (НЕ ExecuteSql): одиночный
|
||||
/// инстанс core, конкурентность на тенанта сериализована воркер-гейтами; rowversion-семантику не вводим.
|
||||
/// Статус тенанта для BudgetStateDto читается из public.tenants тем же контекстом (Ruling 3: suspended
|
||||
/// замораживает ИИ); при отсутствии строки тенанта (защита от рассинхрона) статус трактуется suspended —
|
||||
/// Allowed=false (безопасный дефолт).
|
||||
/// </remarks>
|
||||
public sealed class TenantLimitStore : ITenantLimitStore
|
||||
{
|
||||
private readonly DealDbContext _dbContext;
|
||||
private readonly TokenLimitDefaults _defaults;
|
||||
private readonly TokenBudgetService _budgetService;
|
||||
private readonly Func<DateTimeOffset> _utcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт адаптер с дефолт-бюджетом модуля (TokenBudgetDefaults) и системными часами.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">Системный контекст (public-схема).</param>
|
||||
public TenantLimitStore(DealDbContext dbContext)
|
||||
: this(dbContext, TokenBudgetDefaults.Default, new TokenBudgetService(), () => DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт адаптер с дефолт-бюджетом из конфигурации (Api/AddDealPersistence) и системными часами.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">Системный контекст (public-схема).</param>
|
||||
/// <param name="defaults">Дефолт-параметры лениво создаваемой строки (Ruling 3).</param>
|
||||
public TenantLimitStore(DealDbContext dbContext, TokenLimitDefaults defaults)
|
||||
: this(dbContext, defaults, new TokenBudgetService(), () => DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт адаптер с явными зависимостями (тесты подменяют часы для проверки ленивого reset).
|
||||
/// </summary>
|
||||
/// <param name="dbContext">Системный контекст (public-схема).</param>
|
||||
/// <param name="defaults">Дефолт-параметры лениво создаваемой строки.</param>
|
||||
/// <param name="budgetService">Период-математика (reset/пороги).</param>
|
||||
/// <param name="utcNow">Источник текущего времени (UTC).</param>
|
||||
public TenantLimitStore(
|
||||
DealDbContext dbContext,
|
||||
TokenLimitDefaults defaults,
|
||||
TokenBudgetService budgetService,
|
||||
Func<DateTimeOffset> utcNow)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dbContext);
|
||||
ArgumentNullException.ThrowIfNull(defaults);
|
||||
ArgumentNullException.ThrowIfNull(budgetService);
|
||||
ArgumentNullException.ThrowIfNull(utcNow);
|
||||
_dbContext = dbContext;
|
||||
_defaults = defaults;
|
||||
_budgetService = budgetService;
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantLimitDto> GetOrCreateAsync(
|
||||
Guid tenantId, CancellationToken ct, TokenLimitDefaults? defaults = null)
|
||||
{
|
||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, defaults ?? _defaults, ct);
|
||||
return ToLimitDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
||||
await ResetIfPeriodExpiredAsync(entity, ct);
|
||||
return await ToStateDtoAsync(entity, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BudgetStateDto> AddUsageAsync(Guid tenantId, long tokens, CancellationToken ct)
|
||||
{
|
||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
||||
await ResetIfPeriodExpiredAsync(entity, ct);
|
||||
if (tokens > 0)
|
||||
{
|
||||
if (_dbContext.Database.IsNpgsql())
|
||||
{
|
||||
// Атомарный инкремент одним UPDATE (Security review A12): параллельные списания одного тенанта
|
||||
// (пользовательские ИИ-вызовы) не теряют токены — read-modify-write ниже неатомарен под гонкой.
|
||||
var now = _utcNow();
|
||||
await _dbContext.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"UPDATE public.tenant_limits SET \"UsedTokens\" = \"UsedTokens\" + {tokens}, \"UpdatedAt\" = {now} WHERE \"TenantId\" = {tenantId}",
|
||||
ct);
|
||||
// Синхронизировать отслеживаемую сущность с БД (ToStateDtoAsync читает её поля).
|
||||
await _dbContext.Entry(entity).ReloadAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// InMemory-провайдер (юнит-тесты) не исполняет raw SQL — EF read-modify-write (семантика та же).
|
||||
entity.UsedTokens += tokens;
|
||||
entity.UpdatedAt = _utcNow();
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
return await ToStateDtoAsync(entity, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BudgetStateDto> UpdateBudgetAsync(Guid tenantId, long budgetTokens, string period, CancellationToken ct)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(budgetTokens);
|
||||
if (period != TenantLimitPeriods.Month && period != TenantLimitPeriods.Day)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(period), period, "Период лимита: month или day.");
|
||||
}
|
||||
|
||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
||||
entity.BudgetTokens = budgetTokens;
|
||||
entity.Period = period;
|
||||
entity.Warned80 = false;
|
||||
entity.NotifiedExhausted = false;
|
||||
entity.UpdatedAt = _utcNow();
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return await ToStateDtoAsync(entity, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
||||
await ResetIfPeriodExpiredAsync(entity, ct);
|
||||
if (entity.Warned80 || !_budgetService.IsWarned80(entity.UsedTokens, entity.BudgetTokens))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entity.Warned80 = true;
|
||||
entity.UpdatedAt = _utcNow();
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
||||
await ResetIfPeriodExpiredAsync(entity, ct);
|
||||
if (entity.NotifiedExhausted || !_budgetService.IsExhausted(entity.UsedTokens, entity.BudgetTokens))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entity.NotifiedExhausted = true;
|
||||
entity.UpdatedAt = _utcNow();
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Читает строку лимита; при отсутствии лениво создаёт с дефолт-бюджетом и сохраняет (Ruling 3).
|
||||
// tenantId: Тенант.
|
||||
// defaults: Дефолт-параметры создаваемой строки.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Отслеживаемая строка лимита.
|
||||
private async Task<TenantLimitEntity> LoadOrCreateAsync(Guid tenantId, TokenLimitDefaults defaults, CancellationToken ct)
|
||||
{
|
||||
TenantLimitEntity? entity = await _dbContext.TenantLimits
|
||||
.SingleOrDefaultAsync(x => x.TenantId == tenantId, ct);
|
||||
if (entity is not null)
|
||||
{
|
||||
return entity;
|
||||
}
|
||||
|
||||
var created = new TenantLimitEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
BudgetTokens = defaults.BudgetTokens,
|
||||
Period = defaults.Period,
|
||||
PeriodStart = _utcNow(),
|
||||
UsedTokens = 0,
|
||||
Warned80 = false,
|
||||
NotifiedExhausted = false,
|
||||
UpdatedAt = _utcNow(),
|
||||
};
|
||||
_dbContext.TenantLimits.Add(created);
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return created;
|
||||
}
|
||||
|
||||
// Ленивый reset (Ruling 3): период истёк — обнулить счётчик и флаги, PeriodStart=now, сохранить.
|
||||
// entity: Отслеживаемая строка лимита.
|
||||
// ct: Токен отмены.
|
||||
private async Task ResetIfPeriodExpiredAsync(TenantLimitEntity entity, CancellationToken ct)
|
||||
{
|
||||
if (ResetIfExpired(entity, _utcNow()))
|
||||
{
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Обнуляет накопительные поля строки, если её период завершился к now.
|
||||
// entity: Отслеживаемая строка лимита.
|
||||
// now: Текущий момент (UTC).
|
||||
// Возвращает: True — период был сброшен (строка изменена).
|
||||
private bool ResetIfExpired(TenantLimitEntity entity, DateTimeOffset now)
|
||||
{
|
||||
if (!_budgetService.IsPeriodExpired(entity.PeriodStart, entity.Period, now))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entity.UsedTokens = 0;
|
||||
entity.Warned80 = false;
|
||||
entity.NotifiedExhausted = false;
|
||||
entity.PeriodStart = now;
|
||||
entity.UpdatedAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
|
||||
{
|
||||
// Трогаем только строки с накоплениями (расход/флаги): строки без накоплений чистить нечего.
|
||||
List<TenantLimitEntity> candidates = await _dbContext.TenantLimits
|
||||
.Where(x => x.UsedTokens > 0 || x.Warned80 || x.NotifiedExhausted)
|
||||
.ToListAsync(ct);
|
||||
int reset = 0;
|
||||
foreach (TenantLimitEntity entity in candidates)
|
||||
{
|
||||
if (ResetIfExpired(entity, now))
|
||||
{
|
||||
reset++;
|
||||
}
|
||||
}
|
||||
|
||||
if (reset > 0)
|
||||
{
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return reset;
|
||||
}
|
||||
|
||||
// Собирает состояние бюджета: строка лимита + статус тенанта + Allowed (гейт Ruling 3).
|
||||
// entity: Строка лимита (после ленивого reset).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Состояние бюджета тенанта.
|
||||
private async Task<BudgetStateDto> ToStateDtoAsync(TenantLimitEntity entity, CancellationToken ct)
|
||||
{
|
||||
// Статус тенанта — из public.tenants тем же контекстом; отсутствие строки трактуем suspended
|
||||
// (безопасный дефолт: Allowed=false, приостановка замораживает ИИ, Ruling 10(5)).
|
||||
string? tenantStatus = await _dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Id == entity.TenantId)
|
||||
.Select(t => t.Status)
|
||||
.SingleOrDefaultAsync(ct);
|
||||
string status = tenantStatus ?? TenantStatuses.Suspended;
|
||||
bool allowed = status == TenantStatuses.Active
|
||||
&& !_budgetService.IsExhausted(entity.UsedTokens, entity.BudgetTokens);
|
||||
return new BudgetStateDto(
|
||||
entity.TenantId,
|
||||
entity.BudgetTokens,
|
||||
entity.Period,
|
||||
entity.PeriodStart,
|
||||
entity.UsedTokens,
|
||||
status,
|
||||
allowed,
|
||||
entity.Warned80,
|
||||
entity.NotifiedExhausted);
|
||||
}
|
||||
|
||||
private static TenantLimitDto ToLimitDto(TenantLimitEntity entity) =>
|
||||
new(
|
||||
entity.TenantId,
|
||||
entity.BudgetTokens,
|
||||
entity.Period,
|
||||
entity.PeriodStart,
|
||||
entity.UsedTokens,
|
||||
entity.Warned80,
|
||||
entity.NotifiedExhausted);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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.tenants.
|
||||
/// </summary>
|
||||
/// <remarks>Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1).</remarks>
|
||||
public sealed class TenantRepository(DealDbContext dbContext) : ITenantRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantRecordDto?> FindByIdAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(t => t.Id == id, ct);
|
||||
return entity is null ? null : ToTenantRecordDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
dbContext.Tenants.Add(new TenantEntity
|
||||
{
|
||||
Id = tenant.Id,
|
||||
Name = tenant.Name,
|
||||
Status = tenant.Status,
|
||||
CreatedAt = tenant.CreatedAt,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
var entities = await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.OrderBy(t => t.CreatedAt)
|
||||
.Select(t => ToTenantRecordDto(t))
|
||||
.ToListAsync(ct);
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateStatusAsync(Guid id, string status, CancellationToken ct)
|
||||
{
|
||||
// Отслеживаемая запись + SaveChanges (не ExecuteUpdateAsync): операция редкая (операторская админка),
|
||||
// зато семантика проверяема на InMemory-провайдере в unit-тестах.
|
||||
var entity = await dbContext.Tenants.SingleOrDefaultAsync(t => t.Id == id, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entity.Status = status;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static TenantRecordDto ToTenantRecordDto(TenantEntity entity) =>
|
||||
new(entity.Id, entity.Name, entity.Status, entity.CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация сессии: таблица sessions в схеме public.
|
||||
/// </summary>
|
||||
public sealed class SessionConfiguration : IEntityTypeConfiguration<SessionEntity>
|
||||
{
|
||||
private const int TokenHashMaxLength = 64;
|
||||
private const int LoginMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<SessionEntity> builder)
|
||||
{
|
||||
builder.ToTable("sessions", "public");
|
||||
|
||||
builder.HasKey(x => x.TokenHash);
|
||||
|
||||
builder.Property(x => x.TokenHash).HasMaxLength(TokenHashMaxLength);
|
||||
builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired();
|
||||
builder.Property(x => x.ExpiresAt).IsRequired();
|
||||
builder.Property(x => x.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasIndex(x => x.UserId);
|
||||
builder.HasIndex(x => x.ExpiresAt);
|
||||
|
||||
builder.HasOne<UserEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация тенанта: таблица tenants в схеме public.
|
||||
/// </summary>
|
||||
public sealed class TenantConfiguration : IEntityTypeConfiguration<TenantEntity>
|
||||
{
|
||||
private const int NameMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<TenantEntity> builder)
|
||||
{
|
||||
builder.ToTable("tenants", "public");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Name).HasMaxLength(NameMaxLength).IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Бессхемный DbContext тенанта: таблицы живут в схеме тенанта через search_path.
|
||||
/// </summary>
|
||||
public sealed class TenantDbContext(DbContextOptions<TenantDbContext> options) : DbContext(options)
|
||||
{
|
||||
/// <summary>
|
||||
/// Настройки тенанта (таблица settings).
|
||||
/// </summary>
|
||||
public DbSet<TenantSettingEntity> Settings => Set<TenantSettingEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Карточки дашборда (таблица Cards).
|
||||
/// </summary>
|
||||
public DbSet<CardEntity> Cards => Set<CardEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Комментарии карточек (таблица LeadComments).
|
||||
/// </summary>
|
||||
public DbSet<LeadCommentEntity> LeadComments => Set<LeadCommentEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Журнал действий над карточками для обучения ML (таблица CardMoves).
|
||||
/// </summary>
|
||||
public DbSet<CardMoveEntity> CardMoves => Set<CardMoveEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Очередь обучающих сигналов ML (таблица MlOutbox).
|
||||
/// </summary>
|
||||
public DbSet<MlOutboxEntity> MlOutbox => Set<MlOutboxEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Очередь входящих сообщений пайплайна (таблица QueueItems).
|
||||
/// </summary>
|
||||
public DbSet<QueueItemEntity> QueueItems => Set<QueueItemEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Отсев пайплайна: сообщения, не прошедшие фильтры/ML/ИИ (таблица RejectedItems).
|
||||
/// </summary>
|
||||
public DbSet<RejectedItemEntity> RejectedItems => Set<RejectedItemEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Дедуп-хэши текстов: защита от повторного заведения карточек (таблица DedupEntries).
|
||||
/// </summary>
|
||||
public DbSet<DedupEntryEntity> DedupEntries => Set<DedupEntryEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Единые контейнеры карточек: колонки/стадии/служебные зоны (таблица Containers; этап 9).
|
||||
/// </summary>
|
||||
public DbSet<ContainerEntity> Containers => Set<ContainerEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Каталог диалогов/каналов Telegram (таблица Dialogs; модуль Telegram, Ruling 7).
|
||||
/// </summary>
|
||||
public DbSet<DialogEntity> Dialogs => Set<DialogEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Превью-сообщения диалогов (таблица TgMessages; модуль Telegram, Ruling 7).
|
||||
/// </summary>
|
||||
public DbSet<TgMessageEntity> TgMessages => Set<TgMessageEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Задачи поиска Discovery (таблица DiscTasks; модуль Discovery, Ruling 9).
|
||||
/// </summary>
|
||||
public DbSet<DiscTaskEntity> DiscTasks => Set<DiscTaskEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Кандидаты задач Discovery (таблица DiscCandidates; модуль Discovery, Ruling 9).
|
||||
/// </summary>
|
||||
public DbSet<DiscCandidateEntity> DiscCandidates => Set<DiscCandidateEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Чёрный список Discovery (таблица DiscBlacklist; модуль Discovery, Ruling 9).
|
||||
/// </summary>
|
||||
public DbSet<DiscBlacklistEntity> DiscBlacklist => Set<DiscBlacklistEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Лог событий задач Discovery (таблица DiscLog; модуль Discovery, Ruling 9).
|
||||
/// </summary>
|
||||
public DbSet<DiscLogEntity> DiscLog => Set<DiscLogEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfiguration(new TenantSettingConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new CardConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new LeadCommentConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new CardMoveConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new MlOutboxConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new QueueItemConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new RejectedItemConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new DedupEntryConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ContainerConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new DialogConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new TgMessageConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new DiscTaskConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new DiscCandidateConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new DiscBlacklistConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new DiscLogConfiguration());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Фабрика для dotnet-ef (миграции TenantDbContext). История миграций — без схемы.
|
||||
/// </summary>
|
||||
public sealed class TenantDbDesignTimeFactory : IDesignTimeDbContextFactory<TenantDbContext>
|
||||
{
|
||||
public TenantDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
|
||||
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
|
||||
var options = new DbContextOptionsBuilder<TenantDbContext>()
|
||||
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory"))
|
||||
.Options;
|
||||
return new TenantDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация лимита тенанта: таблица tenant_limits в схеме public.
|
||||
/// </summary>
|
||||
public sealed class TenantLimitConfiguration : IEntityTypeConfiguration<TenantLimitEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantLimitEntity> builder)
|
||||
{
|
||||
builder.ToTable("tenant_limits", "public");
|
||||
|
||||
builder.HasKey(x => x.TenantId);
|
||||
|
||||
builder.Property(x => x.BudgetTokens).IsRequired();
|
||||
builder.Property(x => x.Period).IsRequired().HasDefaultValue("month");
|
||||
builder.Property(x => x.PeriodStart).IsRequired();
|
||||
builder.Property(x => x.UsedTokens).IsRequired();
|
||||
builder.Property(x => x.Warned80).IsRequired();
|
||||
builder.Property(x => x.NotifiedExhausted).IsRequired();
|
||||
builder.Property(x => x.UpdatedAt).IsRequired();
|
||||
|
||||
builder.HasOne<TenantEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.TenantId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация настройки тенанта: таблица settings в схеме тенанта (модель без схемы).
|
||||
/// </summary>
|
||||
public sealed class TenantSettingConfiguration : IEntityTypeConfiguration<TenantSettingEntity>
|
||||
{
|
||||
private const int KeyMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<TenantSettingEntity> builder)
|
||||
{
|
||||
builder.ToTable("settings");
|
||||
|
||||
builder.HasKey(x => x.Key);
|
||||
|
||||
builder.Property(x => x.Key).HasMaxLength(KeyMaxLength);
|
||||
builder.Property(x => x.ValueJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.UpdatedAt).IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация превью-сообщений: таблица TgMessages (модель без схемы; Ruling 7, db.py L67–74).
|
||||
/// </summary>
|
||||
public sealed class TgMessageConfiguration : IEntityTypeConfiguration<TgMessageEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TgMessageEntity> builder)
|
||||
{
|
||||
builder.ToTable("TgMessages");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
// Фолбэк превью диалога читается по диалогу в порядке времени (idx_messages_dialog db.py L74).
|
||||
builder.HasIndex(x => new { x.DialogId, x.MsgAt });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация события расхода токенов: таблица token_usage_events в схеме public (append-only).
|
||||
/// </summary>
|
||||
public sealed class TokenUsageEventConfiguration : IEntityTypeConfiguration<TokenUsageEventEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TokenUsageEventEntity> builder)
|
||||
{
|
||||
builder.ToTable("token_usage_events", "public");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.At).IsRequired();
|
||||
builder.Property(x => x.Provider).IsRequired();
|
||||
builder.Property(x => x.Model).IsRequired();
|
||||
builder.Property(x => x.Kind).IsRequired();
|
||||
builder.Property(x => x.PromptTokens).IsRequired();
|
||||
builder.Property(x => x.CompletionTokens).IsRequired();
|
||||
builder.Property(x => x.TotalTokens).IsRequired();
|
||||
builder.Property(x => x.DetailJson).HasColumnType("text");
|
||||
|
||||
builder.HasIndex(x => new { x.TenantId, x.At });
|
||||
builder.HasIndex(x => x.At);
|
||||
|
||||
builder.HasOne<TenantEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.TenantId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация пользователя: таблица users в схеме public.
|
||||
/// </summary>
|
||||
public sealed class UserConfiguration : IEntityTypeConfiguration<UserEntity>
|
||||
{
|
||||
private const int LoginMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<UserEntity> builder)
|
||||
{
|
||||
builder.ToTable("users", "public");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired();
|
||||
builder.Property(x => x.PasswordHash).IsRequired();
|
||||
builder.Property(x => x.Status).IsRequired().HasDefaultValue("active");
|
||||
builder.Property(x => x.CreatedAt).IsRequired().HasDefaultValueSql("now()");
|
||||
|
||||
builder.HasIndex(x => x.Login).IsUnique();
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
|
||||
builder.HasOne<TenantEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.TenantId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user