Почистить комментарии от упоминаний процесса

Удалены <remarks>, <summary> сжаты до короткой фразы, вырезаны
ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками
на процесс удалены; то же в .proto. Правила обновлены в
docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:39:39 +03:00
parent 5f5538d33b
commit b053d58335
902 changed files with 3902 additions and 12074 deletions
@@ -6,12 +6,6 @@ namespace Deal.Infrastructure.Data;
/// <summary>
/// Строит строку подключения к Postgres с учётом схемы тенанта.
/// </summary>
/// <remarks>
/// Две строки (Security review, least privilege): <c>ConnectionStrings:DealPostgres</c> — прикладная роль
/// runtime (без DDL в проде); <c>ConnectionStrings:DealMigrator</c> (опционально) — служебная роль для DDL
/// (CREATE SCHEMA/миграции схемы). Если мигратор-строка не задана (dev/тесты/один пользователь) — DDL
/// выполняется прикладной строкой (текущее поведение).
/// </remarks>
public sealed class ConnectionStringProvider
{
private readonly string _baseConnectionString;
@@ -39,8 +33,7 @@ public sealed class ConnectionStringProvider
}
/// <summary>
/// Строка подключения для DDL (провижининг схемы/миграции): мигратор-роль, если задана,
/// иначе прикладная (dev/тесты). Search Path — как в <see cref="ForTenant"/>.
/// Строка подключения для DDL
/// </summary>
public string ForSchemaDdl(TenantId? tenantId)
{
@@ -4,7 +4,7 @@ using Deal.SharedKernel.Tenants.Models;
namespace Deal.Infrastructure.Data;
/// <summary>
/// Контекст тенанта на AsyncLocal: пробрасывается через весь запрос.
/// Контекст тенанта на AsyncLocal
/// </summary>
public sealed class TenantContext : ITenantContext
{
@@ -1,7 +1,7 @@
namespace Deal.Infrastructure;
/// <summary>
/// Маркер слоя Infrastructure: используется для DI-сканирования и тестов.
/// Маркер слоя Infrastructure
/// </summary>
public sealed class InfrastructureMarker
{
@@ -4,22 +4,15 @@ using Deal.Modules.Kanban.Application.Models;
namespace Deal.Infrastructure.Integrations.Abstractions;
/// <summary>
/// Порт отправки батча обучения в ml-service (RPC TrainBatch, ml.proto L5255) — для MlOutboxFlushScheduler.
/// Порт отправки батча обучения в ml-service — для MlOutboxFlushScheduler.
/// </summary>
/// <remarks>
/// Отдельный от <see cref="Deal.Contracts.Integrations.Abstractions.IMlClient"/> порт: сигнатура IMlClient не меняется
/// (Self-Review плана L530), а выгрузку очереди делает фоновый флашер (Ruling 6), которому нужен только
/// TrainBatch. Реализуется gRPC-адаптером <see cref="GrpcMlClient"/> и регистрируется только при
/// <c>Services:Ml:UseLocal=false</c> (Local-режиму ml-service не нужен — очередь копится, как в этапе 3).
/// </remarks>
public interface IMlTrainClient
{
/// <summary>
/// Отправляет порцию очереди обучения в ml-service (одна транзакция learn_batch, ml.proto L5255).
/// Отправляет порцию очереди обучения в ml-service.
/// </summary>
/// <param name="items">Строки outbox (text/label/delta; id в запрос не уходит — нужен вызывающему для удаления).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Число применённых примеров (= len(items) при успехе; no-op-пропуски сервис не считает).</returns>
/// <exception cref="Grpc.Core.RpcException">Сервис недоступен/отклонил батч — строки НЕ удаляются (Ruling 6).</exception>
/// <exception cref="Grpc.Core.RpcException">Сервис недоступен/отклонил батч — строки НЕ удаляются.</exception>
public Task<int> TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct);
}
@@ -1,17 +1,8 @@
namespace Deal.Infrastructure.Integrations.Exceptions;
/// <summary>
/// Сбой вызова ИИ-сервиса (провайдер недоступен/не ответил корректно либо ответ без разбора) —
/// сигнал порта IAiClassifier/IAiTools для веток фолбэка вызывающего.
/// Сбой вызова ИИ-сервиса
/// </summary>
/// <remarks>
/// Семантика 1:1 с прототипом, где <c>chat_json</c> бросает RuntimeError (ai.py L115117): воркер Pipeline
/// ловит исключение классификатора → локальный разбор (aiFail, python L11081114), ИИ-фильтр → «пропустить»
/// (L11021106); Discovery-воркер при сбое EvaluateFit падает в эвристику (discovery_eval L186194). Локальные
/// реализации (LocalAiClassifier) детерминированы и этого исключения не бросают. Текст — стабильная строка
/// без секретов и тел ответов (Ruling 13); detail gRPC-ошибки (дружелюбный текст ai-service «ИИ (имя) не
/// ответил корректно…») пробрасывается, когда он есть.
/// </remarks>
public sealed class AiUnavailableException : Exception
{
/// <summary>
@@ -8,7 +8,7 @@ namespace Deal.Infrastructure.Integrations.Extensions;
internal static class RpcExceptionExtensions
{
/// <summary>
/// Ошибки коммуникации, при которых сервис считается недоступным (всё, кроме прикладных статусов).
/// Ошибки коммуникации, при которых сервис считается недоступным
/// </summary>
/// <param name="exception">Исключение RPC.</param>
/// <returns>True — транспорта/контракта health нет (down); false — прикладной статус (не наша зона).</returns>
@@ -4,14 +4,12 @@ using System.Net.Sockets;
namespace Deal.Infrastructure.Integrations.Extensions;
/// <summary>
/// Расширения <see cref="Uri"/> для SSRF-гейта интеграций (проверка приватности адреса).
/// Расширения <see cref="Uri"/> для SSRF-гейта интеграций
/// </summary>
internal static class UriExtensions
{
/// <summary>
/// Проверяет, указывает ли URL на приватный/loopback/link-local адрес (SSRF-гейт).
/// Распознаются IP-литералы (IPv4/IPv6) и имя localhost; DNS-имена считаются публичными
/// (полный egress-контроль с резолвом выполняется на сетевом периметре).
/// Проверяет, указывает ли URL на приватный/loopback/link-local адрес
/// </summary>
/// <param name="uri">Абсолютный http(s)-адрес.</param>
/// <returns>True — адрес приватный/локальный (HTTP к нему запрещён).</returns>
@@ -6,32 +6,22 @@ using Grpc.Net.Client;
namespace Deal.Infrastructure.Integrations.Models;
/// <summary>
/// Транспорт gRPC-клиентов ai-service: общий канал + обязательные metadata (Ruling 1, эталон
/// <c>MlGrpcConnection</c>).
/// Транспорт gRPC-клиентов ai-service
/// </summary>
/// <remarks>
/// Singleton (канал живёт долго и переиспользуется всеми вызовами): endpoint из <see cref="AiServiceOptions"/>,
/// service-token — из env <c>DEAL_SERVICE_TOKEN</c> (Ruling 13: секреты только в env). Dev-транспорт без TLS
/// (Ruling 2); mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским сертификатом
/// и проверяет CA сервера (сертификаты передаются <see cref="MtlsCertificates"/>). Пустой endpoint либо
/// пустой токен при создании — ошибка конфигурации (fail-closed: без токена сервис отвергнет каждый вызов
/// UNAUTHENTICATED, Ruling 1). Автоповторы Grpc.Net.Client отключены (MaxRetryAttempts=0): стратегию повторов
/// держит ai-service (retry 2 с паузами 0.8/2 с, Ruling 5) — ядро повторно не ждёт.
/// </remarks>
public sealed class AiGrpcConnection : IDisposable
{
/// <summary>
/// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor сервисов, Ruling 1).
/// Env-ключ ожидаемого service-token.
/// </summary>
public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
/// <summary>
/// Ключ gRPC-metadata с tenant-id (зеркало AiServiceImpl, Ruling 1).
/// Ключ gRPC-metadata с tenant-id.
/// </summary>
public const string TenantIdMetadataKey = "tenant-id";
/// <summary>
/// Ключ gRPC-metadata с service-token (зеркало AiServiceImpl, Ruling 1).
/// Ключ gRPC-metadata с service-token.
/// </summary>
public const string ServiceTokenMetadataKey = "service-token";
@@ -39,10 +29,10 @@ public sealed class AiGrpcConnection : IDisposable
private readonly string _serviceToken;
/// <summary>
/// Создаёт транспорт ai-service по конфигурации и env-токену (валидация fail-closed).
/// Создаёт транспорт ai-service по конфигурации и env-токену
/// </summary>
/// <param name="options">Конфигурация секции <c>Services:Ai</c> (endpoint).</param>
/// <param name="mtlsCertificates">Сертификаты mTLS (Ruling 6): null — plaintext-канал (dev, флаг выключен).</param>
/// <param name="mtlsCertificates">Сертификаты mTLS: null — plaintext-канал (dev, флаг выключен).</param>
/// <exception cref="InvalidOperationException">Пустой endpoint или пустой DEAL_SERVICE_TOKEN.</exception>
public AiGrpcConnection(AiServiceOptions options, MtlsCertificates? mtlsCertificates = null)
{
@@ -70,13 +60,13 @@ public sealed class AiGrpcConnection : IDisposable
}
/// <summary>
/// Создаёт клиент RPC AiService поверх общего канала (клиент — лёгкий, на каждый вызов).
/// Создаёт клиент RPC AiService поверх общего канала
/// </summary>
/// <returns>Клиент сервиса AI (Filter/Classify/GenerateKeywords/EvaluateFit).</returns>
public AiService.AiServiceClient CreateClient() => new(_channel);
/// <summary>
/// Собирает обязательные metadata вызова: tenant-id + service-token (Ruling 1).
/// Собирает обязательные metadata вызова
/// </summary>
/// <param name="tenantId">Id тенанта (строка, формат N — как в ai-service).</param>
/// <returns>Metadata для CallOptions вызова.</returns>
@@ -6,31 +6,22 @@ using Grpc.Net.Client;
namespace Deal.Infrastructure.Integrations.Models;
/// <summary>
/// Транспорт gRPC-клиента ml-service: общий канал + обязательные metadata (Ruling 1).
/// Транспорт gRPC-клиента ml-service
/// </summary>
/// <remarks>
/// Singleton (канал живёт долго и переиспользуется всеми вызовами): endpoint из <see cref="MlServiceOptions"/>,
/// service-token — из env <c>DEAL_SERVICE_TOKEN</c> (Ruling 13: секреты только в env). Dev-транспорт без TLS
/// (Ruling 2); mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским сертификатом
/// и проверяет CA сервера (сертификаты передаются <see cref="MtlsCertificates"/>). Пустой endpoint либо
/// пустой токен при создании — ошибка конфигурации (fail-closed: без токена сервис отвергнет каждый вызов
/// UNAUTHENTICATED, Ruling 1). Автоповторы Grpc.Net.Client отключены (MaxRetryAttempts=0): стратегию повторов
/// реализует вызывающий (флашер MlOutboxFlushScheduler оставляет строки и пробует в следующем цикле).
/// </remarks>
public sealed class MlGrpcConnection : IDisposable
{
/// <summary>
/// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor сервисов, Ruling 1).
/// Env-ключ ожидаемого service-token.
/// </summary>
public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
/// <summary>
/// Ключ gRPC-metadata с tenant-id (зеркало MlServiceImpl, Ruling 1).
/// Ключ gRPC-metadata с tenant-id.
/// </summary>
public const string TenantIdMetadataKey = "tenant-id";
/// <summary>
/// Ключ gRPC-metadata с service-token (зеркало MlServiceImpl, Ruling 1).
/// Ключ gRPC-metadata с service-token.
/// </summary>
public const string ServiceTokenMetadataKey = "service-token";
@@ -38,10 +29,10 @@ public sealed class MlGrpcConnection : IDisposable
private readonly string _serviceToken;
/// <summary>
/// Создаёт транспорт ml-service по конфигурации и env-токену (валидация fail-closed).
/// Создаёт транспорт ml-service по конфигурации и env-токену
/// </summary>
/// <param name="options">Конфигурация секции <c>Services:Ml</c> (endpoint).</param>
/// <param name="mtlsCertificates">Сертификаты mTLS (Ruling 6): null — plaintext-канал (dev, флаг выключен).</param>
/// <param name="mtlsCertificates">Сертификаты mTLS: null — plaintext-канал (dev, флаг выключен).</param>
/// <exception cref="InvalidOperationException">Пустой endpoint или пустой DEAL_SERVICE_TOKEN.</exception>
public MlGrpcConnection(MlServiceOptions options, MtlsCertificates? mtlsCertificates = null)
{
@@ -69,13 +60,13 @@ public sealed class MlGrpcConnection : IDisposable
}
/// <summary>
/// Создаёт клиент RPC MlService поверх общего канала (клиент — лёгкий, на каждый вызов).
/// Создаёт клиент RPC MlService поверх общего канала
/// </summary>
/// <returns>Клиент сервиса ML (Predict/Status/Reset/TrainBatch).</returns>
public MlService.MlServiceClient CreateClient() => new(_channel);
/// <summary>
/// Собирает обязательные metadata вызова: tenant-id + service-token (Ruling 1).
/// Собирает обязательные metadata вызова
/// </summary>
/// <param name="tenantId">Id тенанта (строка, формат N — как в пуле модели ml-service).</param>
/// <returns>Metadata для CallOptions вызова.</returns>
@@ -6,20 +6,8 @@ using Deal.Infrastructure.Integrations.Options;
namespace Deal.Infrastructure.Integrations.Models;
/// <summary>
/// Загруженный набор сертификатов mTLS внутреннего gRPC (Ruling 6, план Task 13).
/// Загруженный набор сертификатов mTLS внутреннего gRPC.
/// </summary>
/// <remarks>
/// Создаётся один раз на старте процесса, когда <see cref="MtlsOptions.Enabled"/>=true, из файлов
/// deploy/certs (генерация — scripts/mtls-certs.sh); при выключенном флаге <see cref="Load"/> возвращает
/// null — процесс остаётся на plaintext + service-token (Ruling 2 этапа 6). Экземпляр живёт до конца
/// процесса: сертификаты держат Kestrel (серверный) и исходящие gRPC-каналы (клиентский), поэтому
/// IDisposable сознательно нет — преждевременный Dispose сломал бы живые соединения. Fail-fast: при
/// включённом флаге любой пустой/битый путь или пароль — <see cref="InvalidOperationException"/> на старте.
///
/// Проверка второй стороны — цепочка на нашу CA (CustomRootTrust, без revocation): dev-CA не в системном
/// хранилище, поэтому стандартная проверка доверия дала бы RemoteCertificateChainErrors и без кастомного
/// билда цепочки каждое соединение отвергалось бы.
/// </remarks>
public sealed class MtlsCertificates
{
// Роль в сообщениях об ошибках: CA-сертификат (проверка второй стороны).
@@ -42,23 +30,22 @@ public sealed class MtlsCertificates
}
/// <summary>
/// CA-сертификат из CaPem: корень доверия для проверки второй стороны.
/// CA-сертификат из CaPem
/// </summary>
public X509Certificate2 CaCertificate { get; }
/// <summary>
/// Серверный сертификат процесса из PFX (подпись своего Kestrel-gRPC-эндпоинта).
/// Серверный сертификат процесса из PFX
/// </summary>
public X509Certificate2 ServerCertificate { get; }
/// <summary>
/// Клиентский сертификат из PFX (подпись исходящих каналов, общий deal-client).
/// Клиентский сертификат из PFX
/// </summary>
public X509Certificate2 ClientCertificate { get; }
/// <summary>
/// Загружает сертификаты из <paramref name="options"/>: null при выключенном флаге (режим plaintext),
/// иначе — CA + серверный + клиентский с fail-fast на битые пути/пароли.
/// Загружает сертификаты из <paramref name="options"/>
/// </summary>
/// <param name="options">Опции mTLS (env DEAL_MTLS_*).</param>
/// <returns>Набор сертификатов либо null (флаг выключен).</returns>
@@ -78,9 +65,7 @@ public sealed class MtlsCertificates
}
/// <summary>
/// Серверная проверка клиентского сертификата для Kestrel (ClientCertificateValidation): сертификат
/// обязан быть подписан нашей CA (цепочка до CaPem). Стандартные ошибки цепочки (наша CA вне системного
/// хранилища) пересобираются кастомным билдом; иные ошибки (нет сертификата/недоступен) — отказ.
/// Серверная проверка клиентского сертификата для Kestrel
/// </summary>
/// <param name="certificate">Клиентский сертификат из рукопожатия (null — RequireCertificate не выполнен).</param>
/// <param name="chain">Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA).</param>
@@ -109,7 +94,7 @@ public sealed class MtlsCertificates
}
/// <summary>
/// Создаёт HTTP/2-хендлер исходящего канала: клиентский сертификат + проверка CA сервера.
/// Создаёт HTTP/2-хендлер исходящего канала
/// </summary>
/// <returns>Новый SocketsHttpHandler (владелец — создатель; канал GrpcChannel закроет его вместе с собой).</returns>
public SocketsHttpHandler CreateClientHttpHandler()
@@ -3,18 +3,12 @@ using Deal.Infrastructure.Integrations.Services;
namespace Deal.Infrastructure.Integrations.Models;
/// <summary>
/// Результат health-пробы grpc.health.v1 автономного сервиса (Task 10; формирует <see cref="ServiceHealthProbe"/>).
/// Результат health-пробы grpc.health.v1 автономного сервиса.
/// </summary>
/// <remarks>
/// <c>Reachable</c> — сервис ответил на health-RPC (канал/транспорт жив); <c>Serving</c> — статус ответа
/// <c>SERVING</c> (health-контракт в порядке). Комбинации: (true, true) = ok; (true, false) = сервис жив, но
/// не готов (NOT_SERVING/SERVICE_UNKNOWN — «unhealthy»); (false, false) = недоступен (таймаут/нет слушателя —
/// «down»). Значение-сирота (false, true) не возникает (Serving=true без ответа невозможно).
/// </remarks>
public sealed record ServiceHealthResult(bool Reachable, bool Serving)
{
/// <summary>
/// Недоступен: RPC не выполнен (нет соединения/дедлайн/ошибка транспорта).
/// Недоступен: RPC не выполнен
/// </summary>
public static ServiceHealthResult Unreachable { get; } = new(Reachable: false, Serving: false);
}
@@ -6,31 +6,22 @@ using Grpc.Net.Client;
namespace Deal.Infrastructure.Integrations.Models;
/// <summary>
/// Транспорт gRPC-клиента telegram-service: общий канал + обязательные metadata (Ruling 1, план Task 14).
/// Транспорт gRPC-клиента telegram-service
/// </summary>
/// <remarks>
/// Singleton (канал живёт долго и переиспользуется всеми вызовами): endpoint из
/// <see cref="TelegramServiceOptions"/>, service-token — из env <c>DEAL_SERVICE_TOKEN</c> (Ruling 13: секреты
/// только в env). Dev-транспорт без TLS (Ruling 2); mTLS (Ruling 6, Task 13): при включённом флаге канал
/// подписывает запрос клиентским сертификатом и проверяет CA сервера (сертификаты передаются
/// <see cref="MtlsCertificates"/>). Пустой endpoint либо пустой токен при создании — ошибка конфигурации (fail-closed:
/// без токена сервис отвергнет каждый вызов UNAUTHENTICATED, Ruling 1). Автоповторы Grpc.Net.Client отключены
/// (MaxRetryAttempts=0): стратегию повторов реализует вызывающий (фоновые циклы Api пробуют в следующем тике).
/// </remarks>
public sealed class TelegramGrpcConnection : IDisposable
{
/// <summary>
/// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor сервисов, Ruling 1).
/// Env-ключ ожидаемого service-token.
/// </summary>
public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
/// <summary>
/// Ключ gRPC-metadata с tenant-id (зеркало TelegramServiceImpl, Ruling 1).
/// Ключ gRPC-metadata с tenant-id.
/// </summary>
public const string TenantIdMetadataKey = "tenant-id";
/// <summary>
/// Ключ gRPC-metadata с service-token (зеркало TelegramServiceImpl, Ruling 1).
/// Ключ gRPC-metadata с service-token.
/// </summary>
public const string ServiceTokenMetadataKey = "service-token";
@@ -38,10 +29,10 @@ public sealed class TelegramGrpcConnection : IDisposable
private readonly string _serviceToken;
/// <summary>
/// Создаёт транспорт telegram-service по конфигурации и env-токену (валидация fail-closed).
/// Создаёт транспорт telegram-service по конфигурации и env-токену
/// </summary>
/// <param name="options">Конфигурация секции <c>Services:Telegram</c> (endpoint).</param>
/// <param name="mtlsCertificates">Сертификаты mTLS (Ruling 6): null — plaintext-канал (dev, флаг выключен).</param>
/// <param name="mtlsCertificates">Сертификаты mTLS: null — plaintext-канал (dev, флаг выключен).</param>
/// <exception cref="InvalidOperationException">Пустой endpoint или пустой DEAL_SERVICE_TOKEN.</exception>
public TelegramGrpcConnection(TelegramServiceOptions options, MtlsCertificates? mtlsCertificates = null)
{
@@ -69,13 +60,13 @@ public sealed class TelegramGrpcConnection : IDisposable
}
/// <summary>
/// Создаёт клиент RPC TelegramService поверх общего канала (клиент — лёгкий, на каждый вызов).
/// Создаёт клиент RPC TelegramService поверх общего канала
/// </summary>
/// <returns>Клиент сервиса Telegram (команды ядра наружу).</returns>
public TelegramService.TelegramServiceClient CreateClient() => new(_channel);
/// <summary>
/// Собирает обязательные metadata вызова: tenant-id + service-token (Ruling 1).
/// Собирает обязательные metadata вызова
/// </summary>
/// <param name="tenantId">Id тенанта (строка, формат N — как в сессиях telegram-service).</param>
/// <returns>Metadata для CallOptions вызова.</returns>
@@ -1,34 +1,27 @@
namespace Deal.Infrastructure.Integrations.Options;
/// <summary>
/// Конфигурация клиента AI-сервиса — секция <c>Services:Ai</c> (Ruling 6, план Task 15).
/// Конфигурация клиента AI-сервиса — секция <c>Services:Ai</c>.
/// </summary>
/// <remarks>
/// По умолчанию dev = Local-адаптеры: <c>UseLocal=true</c> регистрирует LocalAiClassifier/LocalAiTools
/// (фолбэк этапов 4–5: локальный разбор ядра, фильтр пропускает, ИИ-инструменты не поддерживаются), реальный
/// ai-service подключается <c>Services:Ai:UseLocal=false</c> + endpoint (env
/// <c>SERVICES__AI__USELOCAL=false</c>, <c>SERVICES__AI__ENDPOINT=http://localhost:5102</c>, compose — Ruling 12).
/// Выбор реализации — на старте, логики переключения в рантайме нет (Ruling 6).
/// </remarks>
public sealed class AiServiceOptions
{
/// <summary>
/// Имя секции конфигурации (appsettings.json / env-префикс SERVICES__AI__*).
/// Имя секции конфигурации
/// </summary>
public const string SectionName = "Services:Ai";
/// <summary>
/// Endpoint ai-service по умолчанию (dev-порт сервиса, Ruling 12).
/// Endpoint ai-service по умолчанию.
/// </summary>
public const string DefaultEndpoint = "http://localhost:5102";
/// <summary>
/// True — Local-адаптеры (default), false — gRPC-клиенты GrpcAiClassifier/GrpcAiTools.
/// True — Local-адаптеры
/// </summary>
public bool UseLocal { get; set; } = true;
/// <summary>
/// Базовый адрес ai-service (http://host:port; только без TLS — Ruling 2).
/// Базовый адрес ai-service.
/// </summary>
public string Endpoint { get; set; } = DefaultEndpoint;
}
@@ -1,33 +1,27 @@
namespace Deal.Infrastructure.Integrations.Options;
/// <summary>
/// Конфигурация клиента ML-сервиса — секция <c>Services:Ml</c> (Ruling 6, план Task 16).
/// Конфигурация клиента ML-сервиса — секция <c>Services:Ml</c>.
/// </summary>
/// <remarks>
/// По умолчанию dev = Local-заглушка: <c>UseLocal=true</c> регистрирует <c>LocalMlClient</c>
/// (фолбэк этапов 2–5), реальный ml-service подключается <c>Services:Ml:UseLocal=false</c> +
/// endpoint (env <c>SERVICES__ML__USELOCAL=false</c>, <c>SERVICES__ML__ENDPOINT=http://localhost:5103</c>,
/// compose — Ruling 12). Выбор реализации — на старте, логики переключения в рантайме нет (Ruling 6).
/// </remarks>
public sealed class MlServiceOptions
{
/// <summary>
/// Имя секции конфигурации (appsettings.json / env-префикс SERVICES__ML__*).
/// Имя секции конфигурации
/// </summary>
public const string SectionName = "Services:Ml";
/// <summary>
/// Endpoint ml-service по умолчанию (dev-порт сервиса, Ruling 12).
/// Endpoint ml-service по умолчанию.
/// </summary>
public const string DefaultEndpoint = "http://localhost:5103";
/// <summary>
/// True — Local-заглушка LocalMlClient (default), false — gRPC-клиент GrpcMlClient.
/// True — Local-заглушка LocalMlClient
/// </summary>
public bool UseLocal { get; set; } = true;
/// <summary>
/// Базовый адрес ml-service (http://host:port; только без TLS — Ruling 2).
/// Базовый адрес ml-service.
/// </summary>
public string Endpoint { get; set; } = DefaultEndpoint;
}
@@ -3,26 +3,17 @@ using Microsoft.Extensions.Configuration;
namespace Deal.Infrastructure.Integrations.Options;
/// <summary>
/// Конфигурация mTLS-транспорта внутреннего gRPC (Ruling 6, план Task 13).
/// Конфигурация mTLS-транспорта внутреннего gRPC.
/// </summary>
/// <remarks>
/// Только env (Ruling 13: секреты/пути сертификатов не читаются из appsettings): флаг
/// <c>DEAL_MTLS_ENABLED</c> и пути/пароли <c>DEAL_MTLS_*</c> из Ruling 6. Dev-дефолт — выключено
/// (<see cref="Enabled"/> = false): процессы остаются на plaintext + service-token (Ruling 2 этапа 6);
/// PROD включает флаг env из compose-prod (Task 14; файлы монтируются из deploy/certs/, генерация —
/// scripts/mtls-certs.sh). Каждый процесс несёт и серверную, и клиентскую роль (Ruling 6): серверный PFX —
/// для своего Kestrel-gRPC (у сервисов свой, у core — ингресс :5082), клиентский — для исходящих каналов
/// (общий deal-client), CA — для проверки второй стороны.
/// </remarks>
public sealed class MtlsOptions
{
/// <summary>
/// Env-ключ флага: 1/true включает mTLS (как прочие env-флаги сервиса).
/// Env-ключ флага: 1/true включает mTLS
/// </summary>
public const string EnabledEnvKey = "DEAL_MTLS_ENABLED";
/// <summary>
/// Env-ключ пути к PFX серверного сертификата процесса (Kestrel-gRPC).
/// Env-ключ пути к PFX серверного сертификата процесса
/// </summary>
public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX";
@@ -32,7 +23,7 @@ public sealed class MtlsOptions
public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD";
/// <summary>
/// Env-ключ пути к PFX клиентского сертификата (общий deal-client исходящих каналов).
/// Env-ключ пути к PFX клиентского сертификата
/// </summary>
public const string ClientCertPfxEnvKey = "DEAL_MTLS_CLIENT_CERT_PFX";
@@ -42,7 +33,7 @@ public sealed class MtlsOptions
public const string ClientCertPasswordEnvKey = "DEAL_MTLS_CLIENT_CERT_PASSWORD";
/// <summary>
/// Env-ключ пути к PEM dev-CA (проверка сертификата второй стороны).
/// Env-ключ пути к PEM dev-CA
/// </summary>
public const string CaPemEnvKey = "DEAL_MTLS_CA_PEM";
@@ -52,32 +43,32 @@ public sealed class MtlsOptions
public bool Enabled { get; init; }
/// <summary>
/// Путь к PFX серверного сертификата процесса (см. <see cref="ServerCertPfxEnvKey"/>).
/// Путь к PFX серверного сертификата процесса
/// </summary>
public string ServerCertPfx { get; init; } = string.Empty;
/// <summary>
/// Пароль серверного PFX (см. <see cref="ServerCertPasswordEnvKey"/>).
/// Пароль серверного PFX
/// </summary>
public string ServerCertPassword { get; init; } = string.Empty;
/// <summary>
/// Путь к PFX клиентского сертификата (см. <see cref="ClientCertPfxEnvKey"/>).
/// Путь к PFX клиентского сертификата
/// </summary>
public string ClientCertPfx { get; init; } = string.Empty;
/// <summary>
/// Пароль клиентского PFX (см. <see cref="ClientCertPasswordEnvKey"/>).
/// Пароль клиентского PFX
/// </summary>
public string ClientCertPassword { get; init; } = string.Empty;
/// <summary>
/// Путь к PEM-файлу dev-CA (см. <see cref="CaPemEnvKey"/>).
/// Путь к PEM-файлу dev-CA
/// </summary>
public string CaPem { get; init; } = string.Empty;
/// <summary>
/// Читает опции из конфигурации хоста (env-ключи DEAL_MTLS_*, только env — Ruling 13).
/// Читает опции из конфигурации хоста.
/// </summary>
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
/// <returns>Опции mTLS (флаг выключен — остальные поля пустые).</returns>
@@ -96,7 +87,7 @@ public sealed class MtlsOptions
}
/// <summary>
/// Разбирает значение флага DEAL_MTLS_ENABLED: «1»/«true» (без учёта регистра) — включено.
/// Разбирает значение флага DEAL_MTLS_ENABLED
/// </summary>
/// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param>
public static bool IsEnabled(string? rawValue)
@@ -1,34 +1,27 @@
namespace Deal.Infrastructure.Integrations.Options;
/// <summary>
/// Конфигурация клиента telegram-service — секция <c>Services:Telegram</c> (Ruling 6, план Task 14).
/// Конфигурация клиента telegram-service — секция <c>Services:Telegram</c>.
/// </summary>
/// <remarks>
/// По умолчанию dev = Local-заглушка: <c>UseLocal=true</c> регистрирует <c>LocalTelegramGateway</c>
/// (нейтральный no-op/idle — реальный telegram-service в dev не поднят), реальный сервис подключается
/// <c>Services:Telegram:UseLocal=false</c> + endpoint (env <c>SERVICES__TELEGRAM__USELOCAL=false</c>,
/// <c>SERVICES__TELEGRAM__ENDPOINT=http://localhost:5101</c>, compose — Ruling 12). Выбор реализации — на
/// старте, логики переключения в рантайме нет (Ruling 6).
/// </remarks>
public sealed class TelegramServiceOptions
{
/// <summary>
/// Имя секции конфигурации (appsettings.json / env-префикс SERVICES__TELEGRAM__*).
/// Имя секции конфигурации
/// </summary>
public const string SectionName = "Services:Telegram";
/// <summary>
/// Endpoint telegram-service по умолчанию (dev-порт сервиса, Ruling 12).
/// Endpoint telegram-service по умолчанию.
/// </summary>
public const string DefaultEndpoint = "http://localhost:5101";
/// <summary>
/// True — Local-заглушка LocalTelegramGateway (default), false — gRPC-клиент GrpcTelegramClient.
/// True — Local-заглушка LocalTelegramGateway
/// </summary>
public bool UseLocal { get; set; } = true;
/// <summary>
/// Базовый адрес telegram-service (http://host:port; только без TLS — Ruling 2).
/// Базовый адрес telegram-service.
/// </summary>
public string Endpoint { get; set; } = DefaultEndpoint;
}
@@ -7,29 +7,16 @@ using Deal.Modules.Settings.Application.Models;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// HTTP-реализация проверки подключения к AI-провайдеру (Ruling 7; 1:1 settings_routes.py L195219).
/// HTTP-реализация проверки подключения к AI-провайдеру.
/// </summary>
/// <remarks>
/// Лёгкая проверка БЕЗ LLM-вызовов: для OpenAI-совместимых — GET {base}/models, для Anthropic
/// (api_style <c>"anthropic"</c>) — GET {base}/v1/models c заголовком x-api-key. Без ключа и для
/// локальных провайдеров (Ollama/LM Studio) HTTP не выполняется — короткие ветки ответа.
/// Таймаут клиента — 12 с (HttpClient настраивается DI-регистрацией AddHttpClient в Deal.Api,
/// см. <see cref="RequestTimeoutSeconds"/>). Ключ в ответ не попадает: только keySet/keyMasked
/// (маска — <c>ai.py</c> mask_key L5358).
/// SSRF-контур dev-режима (см. отчёт Task 6): провайдер обязан быть из фиксированного каталога
/// <see cref="AiProviders"/> (allowlist), base URL — только абсолютный http(s)-адрес; host-level
/// рестрикции нет (локальные серверы на LAN + ветка «недоступный хост» приёмки плана).
/// </remarks>
public sealed class AiConnectionChecker : IAiConnectionChecker
{
/// <summary>
/// Таймаут HTTP-запроса проверки в секундах (Ruling 7 — 12 с); применяется DI-регистрацией клиента.
/// Таймаут HTTP-запроса проверки в секундах; применяется DI-регистрацией клиента.
/// </summary>
public const int RequestTimeoutSeconds = 12;
// ── Фиксированные сообщения веток (Ruling 7, 1:1 с прототипом) ──
// Сообщение ветки «локальный провайдер» (вместо HTTP — ping на этапе 6).
private const string LocalServerMessageTemplate = "Локальный сервер «{0}» (ping в проде)";
// Сообщение ветки «API-ключ не задан».
@@ -56,7 +43,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
// Сообщение SSRF-гейта: base URL не абсолютный http(s).
private const string InvalidBaseUrlMessage = "Недопустимый Base URL (ожидается http/https)";
// ── Константы протокола (референс settings_routes.py L206209) ──
// Значение api_style провайдера Anthropic (AiProviderDefinition.ApiStyle).
private const string AnthropicApiStyle = "anthropic";
@@ -108,7 +94,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage);
}
// Локальный провайдер (Ollama/LM Studio): HTTP наружу не ходим (Ruling 7 — ветка до ключа).
if (request.IsLocal)
{
return BuildResult(request, name, ok: true, message: string.Format(LocalServerMessageTemplate, name));
@@ -157,7 +142,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Сработал HttpClient.Timeout (12 с) — ветка сетевого сбоя (прототип ловит все исключения).
return BuildResult(request, name, ok: false, message: TimeoutMessage);
}
catch (HttpRequestException exception)
@@ -167,7 +151,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
}
}
// Собирает ответ ветки: {ok, message} + статус провайдера (Ruling 7).
// request: Запрос проверки (поля статуса провайдера).
// name: Имя провайдера из каталога AiProviders.
// ok: Результат подключения.
@@ -191,7 +174,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
KeyMasked: MaskKey(request.ApiKey));
}
// Маска ключа: пусто → "", len ≤ 8 → «x…», иначе «1234…5678» (ai.py mask_key L5358).
// key: Ключ открытым текстом.
// Возвращает: Маскированная строка.
private static string MaskKey(string key)
@@ -209,7 +191,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
return string.Concat(key.AsSpan(0, 4), "…", key.AsSpan(key.Length - 4));
}
// Строит URL проверки: {base}/models или {base}/v1/models (Anthropic), как в ai_check L206207.
// baseUrl: Эффективный базовый URL из конфигурации провайдера.
// apiStyle: Стиль API провайдера (null — OpenAI-совместимый).
// modelsUri: URL списка моделей (валиден только при возврате true).
@@ -226,7 +207,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
return false;
}
// rstrip("/") как в прототипе: baseUrl из настроек может заканчиваться слэшем.
string root = baseUrl.TrimEnd('/');
string relativePath = apiStyle == AnthropicApiStyle ? AnthropicModelsPath : OpenAiModelsPath;
if (!Uri.TryCreate(root + relativePath, UriKind.Absolute, out Uri? endpoint))
@@ -6,19 +6,8 @@ using Deal.Modules.Settings.Application.Models;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Собирает конфиг активного ИИ-провайдера для запросов ai-service (Ruling 5: ядро расшифровывает
/// aiConfigs и передаёт ProviderConfig в теле каждого запроса; сервис настроек тенанта не знает).
/// Собирает конфиг активного ИИ-провайдера для запросов ai-service.
/// </summary>
/// <remarks>
/// Эффективный конфиг 1:1 с python <c>ai.py _cfg()</c> L2533 и формой ProviderConfig (ai.proto L7085):
/// активный провайдер — настройка <c>aiProvider</c> (дефолт «deepseek»), каталог — <see cref="AiProviders"/>
/// (fallback на первый — deepseek, как python L28); из переопределения <c>aiConfigs</c> берутся
/// apiKey/baseUrl/model, отсутствующие поля дополняются дефолтами каталога (base провайдера, первая модель);
/// apiKey расшифровывается (значения <c>enc:</c>+… через <see cref="ISecretCipher"/>; незашифрованные ранних
/// версий — как есть, python crypto.decrypt_text L5261); api_style провайдера — из каталога (Anthropic —
/// «anthropic», остальные — пусто = OpenAI-совместимый). Scoped: читает KV-настройки тенанта (ISettingsStore →
/// scoped TenantDbContext запроса), как LocalAiClassifier/GrpcMlClient.
/// </remarks>
public sealed class AiProviderConfigBuilder
{
// Ключ aiConfigs: поле apiKey переопределения провайдера.
@@ -30,7 +19,6 @@ public sealed class AiProviderConfigBuilder
// Ключ aiConfigs: поле model переопределения провайдера.
private const string ModelField = "model";
// Префикс зашифрованного значения apiKey (crypto.py L49: enc: + Base64(nonce‖ct‖tag)).
private const string EncryptedPrefix = "enc:";
private readonly ISettingsStore _store;
@@ -40,7 +28,7 @@ public sealed class AiProviderConfigBuilder
/// Создаёт сборщик конфига провайдера.
/// </summary>
/// <param name="store">KV-хранилище настроек тенанта (aiProvider/aiConfigs).</param>
/// <param name="secretCipher">Расшифровка секрета aiConfigs.apiKey (AES-GCM, Ruling 2).</param>
/// <param name="secretCipher">Расшифровка секрета aiConfigs.apiKey.</param>
public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher)
{
ArgumentNullException.ThrowIfNull(store);
@@ -52,7 +40,6 @@ public sealed class AiProviderConfigBuilder
/// <summary>
/// Собирает ProviderConfig активного провайдера для тела запроса ai-service.
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Конфиг: provider_id/base/model/api_key (расшифрованный)/api_style (см. ai.proto).</returns>
public async Task<ProviderConfig> BuildAsync(CancellationToken ct)
{
@@ -100,7 +87,6 @@ public sealed class AiProviderConfigBuilder
return config;
}
// Активный провайдер из настройки aiProvider (дефолт «deepseek», python L26).
// ct: Токен отмены.
// Возвращает: Id провайдера (каталога AiProviders).
private async Task<string> ReadProviderIdAsync(CancellationToken ct)
@@ -10,25 +10,8 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Декоратор бюджетного гейта порта <see cref="IAiClassifier"/> (Ruling 3, Task 9): поверх «платного»
/// исполнителя (gRPC-адаптер <see cref="GrpcAiClassifier"/>) перед каждым вызовом спрашивает гейт и при запрете
/// ИИ уводит вызов на бесплатную локальную реализацию <see cref="LocalAiClassifier"/>.
/// Декоратор бюджетного гейта порта <see cref="IAiClassifier"/>
/// </summary>
/// <remarks>
/// Гейт — <see cref="ITenantLimitStore.GetStateAsync"/> (public.tenant_limits + статус тенанта, Task 8):
/// вызов разрешён, когда <see cref="BudgetStateDto.Allowed"/> — тенант active и бюджет периода не исчерпан
/// (UsedTokens ≥ BudgetTokens; лимит 0 запрещает ИИ уже с нулевого расхода). Запрещено — исчерпание бюджета
/// либо приостановка тенанта (suspended замораживает ИИ, Ruling 3/10(5)). При запрете фильтр/классификация
/// выполняются Local-реализацией — семантика aiEnabled=false/aiFail этапов 45: фильтр {pass:true, skipped:true},
/// разбор ядра <see cref="LocalFieldsParser"/> (детерминированный, бесплатный) — приём и обработка сообщений не
/// блокируются, платный ИИ не зовётся и бюджет не расходуется. Списание usage остаётся внутри gRPC-адаптера
/// (<see cref="TokenUsageRecorder"/>, Task 8) и выполняется только по реальным платным ответам. Регистрируется
/// в <c>AddDealIntegrations</c> только при <c>Services:Ai:UseLocal=false</c> (порядок Grpc → Budgeted → наружу);
/// в Local-режиме адаптер и так бесплатен — декоратор не нужен. Ошибки платного исполнителя
/// (<see cref="AiUnavailableException"/>) пробрасываются как раньше — ветки фолбэка воркера не меняются.
/// SSE-уведомления о пересечении порогов 80/100% бюджета публикует BudgetAlertScheduler (Api-слой): здесь
/// запрет только логируется (Ruling 13: стабильные строки, без секретов).
/// </remarks>
public sealed class BudgetedAiClassifier : IAiClassifier
{
// Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту).
@@ -80,7 +63,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier
}
// Запрет гейта — фильтр через Local-реализацию {pass:true, skipped:true} (семантика «фильтр недоступен»,
// Ruling 3): сообщение не блокируется, платный фильтр не зовётся.
_logger.LogDebug(
"ИИ-фильтр: {Reason} — Local-пропуск (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
return await _localClassifier.FilterAsync(text, ct);
@@ -94,14 +76,12 @@ public sealed class BudgetedAiClassifier : IAiClassifier
return await _paidClassifier.ClassifyAsync(text, ct);
}
// Запрет гейта — локальный разбор ядра (семантика aiEnabled=false/aiFail, Ruling 3): карточка строится
// без платного ИИ, приём не блокируется.
_logger.LogDebug(
"ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
return await _localClassifier.ClassifyAsync(text, ct);
}
// Бюджетный гейт вызова (Ruling 3): true — платный ИИ разрешён (тенант active и бюджет не исчерпан).
// ct: Токен отмены.
// Возвращает: True — можно звать платного исполнителя.
private async Task<bool> IsPaidAllowedAsync(CancellationToken ct)
@@ -10,36 +10,16 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Декоратор бюджетного гейта порта <see cref="IAiTools"/> (Ruling 3, Task 9): поверх «платного»
/// исполнителя (gRPC-адаптер <see cref="GrpcAiTools"/>) перед каждым вызовом спрашивает гейт и при запрете ИИ
/// не зовёт платный инструмент: <see cref="EvaluateFitAsync"/> бросает <see cref="AiUnavailableException"/>
/// (вызывающий — воркер Discovery — сам уходит в эвристику, код не меняется, Ruling 10),
/// <see cref="GenerateKeywordsAsync"/> отдаёт мягкую ошибку {ok:false, keywords:[], error} (Ruling 11: эндпоинт
/// отвечает HTTP 200 {keywords: [], error}).
/// Декоратор бюджетного гейта порта <see cref="IAiTools"/>
/// </summary>
/// <remarks>
/// Гейт — <see cref="ITenantLimitStore.GetStateAsync"/> (public.tenant_limits + статус тенанта, Task 8): вызов
/// разрешён, когда <see cref="BudgetStateDto.Allowed"/> — тенант active и бюджет периода не исчерпан; запрещено —
/// исчерпание либо приостановка тенанта (suspended замораживает ИИ, Ruling 3/10(5)). Тексты запрета различают
/// приостановку и исчерпание по <see cref="BudgetStateDto.Status"/> (стабильные строки без секретов, Ruling 13).
/// Ошибки платного исполнителя (<see cref="AiUnavailableException"/>) пробрасываются как раньше — ветки фолбэка
/// Discovery не меняются. Списание usage остаётся внутри gRPC-адаптера (<see cref="TokenUsageRecorder"/>, Task 8)
/// и выполняется только по реальным платным ответам. Регистрируется в <c>AddDealIntegrations</c> только при
/// <c>Services:Ai:UseLocal=false</c> (порядок Grpc → Budgeted → наружу). SSE-уведомления о пересечении порогов
/// 80/100% бюджета публикует BudgetAlertScheduler (Api-слой): здесь запрет только логируется.
/// </remarks>
public sealed class BudgetedAiTools : IAiTools
{
// Текст мягкой ошибки generate-keywords при исчерпанном бюджете (Ruling 3).
private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна";
// Текст мягкой ошибки generate-keywords при приостановке тенанта (Ruling 3/10(5)).
private const string SuspendedKeywordsError = "Тенант приостановлен — генерация ключевых слов недоступна";
// Текст исключения EvaluateFit при исчерпанном бюджете (семантика локальной обработки, Ruling 3).
private const string ExhaustedFitError = "ИИ-бюджет исчерпан — обработка в локальном режиме";
// Текст исключения EvaluateFit при приостановке тенанта (Ruling 3/10(5)).
private const string SuspendedFitError = "Тенант приостановлен — ИИ-оценка заморожена";
private readonly IAiTools _paidTools;
@@ -79,7 +59,6 @@ public sealed class BudgetedAiTools : IAiTools
return await _paidTools.GenerateKeywordsAsync(description, ct);
}
// Мягкая ошибка для UI (Ruling 3/11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200.
_logger.LogDebug(
"generate-keywords: {Reason} — мягкая ошибка (тенант {TenantId})",
DenyLogText(state),
@@ -103,8 +82,6 @@ public sealed class BudgetedAiTools : IAiTools
return await _paidTools.EvaluateFitAsync(text, description, keywords, ct);
}
// Сбой ИИ-оценки не роняет оценку кандидата: воркер Discovery падает в эвристику (Ruling 3/10,
// python L186194 — код вызывающего не меняется).
_logger.LogDebug(
"evaluate-fit: {Reason} — эвристика (тенант {TenantId})",
DenyLogText(state),
@@ -113,7 +90,6 @@ public sealed class BudgetedAiTools : IAiTools
state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError);
}
// Текущее состояние бюджета тенанта (ленивый reset периода + Allowed/Status для гейта, Task 9).
// ct: Токен отмены.
// Возвращает: Состояние бюджета тенанта на сейчас.
private async Task<BudgetStateDto> GateStateAsync(CancellationToken ct)
@@ -6,25 +6,15 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// HTTP-источник курсов ЦБ РФ: GET daily_json.js (Ruling 6, Task 8; 1:1 rates.py L4359).
/// HTTP-источник курсов ЦБ РФ
/// </summary>
/// <remarks>
/// Запрос — GET <c>https://www.cbr-xml-daily.ru/daily_json.js</c> (JSON-зеркало ЦБ). SSRF-контур: URL —
/// фиксированная константа (allowlist), тенант не управляет адресом источника (в отличие от baseUrl
/// AI-провайдеров, Task 6). Таймаут клиента — 15 с (python: <c>httpx timeout=15</c>), задаётся
/// DI-регистрацией AddHttpClient в Deal.Api. Парсинг: <c>Valute[code].Value / Nominal</c> (1 единица
/// валюты в рублях; Nominal может быть &gt; 1, напр. 100 KZT), к курсам добавляется <c>RUB:1</c>.
/// Любой сбой (HTTP-код ≠ 2xx, нераспознанное тело/запись, сетевая ошибка) → null + warning — кэш
/// RatesService при этом не трогает (Ruling 6). Отмена вызывающего пробрасывается (не «сбой»).
/// </remarks>
public sealed class CbrRateSource : IRatesSource
{
/// <summary>
/// Таймаут HTTP-запроса в секундах (python rates.py L46: <c>timeout=15</c>).
/// Таймаут HTTP-запроса в секундах.
/// </summary>
public const int RequestTimeoutSeconds = 15;
// URL JSON-зеркала курсов ЦБ (constants.py L52). Фиксированный — SSRF-allowlist.
private const string CbrUrl = "https://www.cbr-xml-daily.ru/daily_json.js";
// Корневой объект ответа: валюта → {Value, Nominal, …}.
@@ -39,7 +29,6 @@ public sealed class CbrRateSource : IRatesSource
// Базовая валюта ответа: курсы даются к рублю.
private const string BaseCurrency = "RUB";
// Курс рубля к рублю (всегда 1.0, rates.py L50).
private const double RubToRubRate = 1.0;
private readonly HttpClient _httpClient;
@@ -74,7 +63,6 @@ public sealed class CbrRateSource : IRatesSource
}
catch (Exception exception)
{
// Любой сбой HTTP/парсинга = неуспех источника (python ловит все исключения, rates.py L5759).
_logger.LogWarning("CBR fetch failed: {Reason}", exception.Message);
return null;
}
@@ -103,7 +91,6 @@ public sealed class CbrRateSource : IRatesSource
{
if (!TryParseCurrency(currency, out double rate))
{
// Нераспознанная запись валюты: как и исключение python внутри цикла, роняет весь fetch.
return null;
}
@@ -113,7 +100,6 @@ public sealed class CbrRateSource : IRatesSource
return rates;
}
// Разбирает одну запись валюты: курс = Value / Nominal, округлён до 6 знаков (rates.py L5155).
// currency: Пара «код валюты → объект {Value, Nominal}».
// rate: Курс единицы валюты к рублю (валиден при возврате true).
// Возвращает: True — запись распознана; False — повреждённая запись (весь fetch — сбой).
@@ -127,7 +113,6 @@ public sealed class CbrRateSource : IRatesSource
JsonElement item = currency.Value;
// Значение по умолчанию, как в python: отсутствующий Value → 0, Nominal → 1 (иначе — сбой).
double value = 0;
if (item.TryGetProperty(ValuePropertyName, out JsonElement valueElement))
{
@@ -12,39 +12,17 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта <see cref="IAiClassifier"/> к автономному ai-service (Ruling 5/6, план Task 15
/// L412434).
/// gRPC-адаптер порта <see cref="IAiClassifier"/> к автономному ai-service.
/// </summary>
/// <remarks>
/// Регистрируется вместо Local-реализации при <c>Services:Ai:UseLocal=false</c> (выбор на старте, Ruling 6).
/// Поведение 1:1 с <c>backend/app/services/ai.py</c> filter_incoming L188198 / classify L218258 и ai.proto:
/// <list type="bullet">
/// <item><see cref="FilterAsync"/> — RPC Filter (deadline 120 с, README контрактов): заполненный aiFilterPrompt
/// из настроек (<see cref="AiClassifyContextBuilder"/>) + текст ≤4000; недоступность провайдера → RPC-ошибка →
/// <see cref="AiUnavailableException"/> (воркер отвечает «пропустить», python L11021106);</item>
/// <item><see cref="ClassifyAsync"/> — RPC Classify: system_prompt = aiPrompt+cardPrompt, user-контекст «Доски +
/// примеры разметки + Сообщение» (собирает билдер по данным тенанта, python L226251); ok=false/сбой →
/// <see cref="AiUnavailableException"/> (воркер собирает локальный разбор, aiFail, python L11081114);
/// ok=true → строгий маппинг JSON в <see cref="AiParsedCardDto"/> (<see cref="AiRawCardMapper"/>, 1:1
/// normalize_stack/clean_budget/build_contacts);</item>
/// <item>конфиг провайдера на каждый запрос — <see cref="AiProviderConfigBuilder"/> (Ruling 5: core читает
/// aiConfigs тенанта, расшифровывает apiKey); usage ответов списывается с бюджета тенанта в tenant_limits и
/// копится в lifetime-KV aiTokenUsage (<see cref="TokenUsageRecorder"/>, Ruling 3 этапа 7).</item>
/// </list>
/// Каждый вызов несёт metadata tenant-id + service-token (<see cref="AiGrpcConnection"/>, Ruling 1). Scoped:
/// настройки/доски/журнал тенанта читаются через scoped-хранилища (ISettingsStore/ICardStore), как
/// LocalAiClassifier/GrpcMlClient. Ветки выключателей aiEnabled/aiFilterEnabled порт не читает — их
/// отрабатывает воркер (Ruling 5 этапа 4).
/// </remarks>
public sealed class GrpcAiClassifier : IAiClassifier
{
/// <summary>
/// Deadline RPC ai-service — 120 с (README контрактов: провайдер 90/60 с + ретраи 0.8/2 с).
/// Deadline RPC ai-service — 120 с
/// </summary>
public const int RpcDeadlineSeconds = 120;
/// <summary>
/// Лимит текста сообщения фильтра (ai.py filter_incoming L193: text[:4000]).
/// Лимит текста сообщения фильтра.
/// </summary>
public const int MaxFilterTextCodePoints = 4000;
@@ -112,7 +90,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
// Фильтр применён (воркер звал его только при aiFilterEnabled и не force) — skipped=false
// (python filter_incoming L194198: {pass, reason, skipped:false}).
return new AiFilterResultDto(
Pass: reply.Pass,
Reason: reply.HasReason ? reply.Reason : null,
@@ -120,7 +97,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
}
catch (RpcException exception)
{
// Провайдер/сервис недоступен — воркер отвечает «пропустить» (python L11021106: r2=pass+skipped).
_logger.LogDebug(exception, "ИИ-фильтр недоступен (тенант {TenantId})", tenantId.Value);
throw new AiUnavailableException(ErrorText(exception));
}
@@ -154,7 +130,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
}
catch (RpcException exception)
{
// Классификатор недоступен — как raw={} в прототипе (L1112–1114): локальный разбор, aiFail.
_logger.LogDebug(exception, "ИИ-классификация недоступна (тенант {TenantId})", tenantId.Value);
throw new AiUnavailableException(ErrorText(exception));
}
@@ -168,7 +143,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
if (!reply.Ok)
{
// Модель не вернула разбираемый JSON после ретраев — контрактная ok=false (README ai.proto):
// ядро трактует как «разбора нет» и падает в локальный путь (python: RuntimeError → raw={}).
_logger.LogWarning("ИИ-классификация: ok=false (тенант {TenantId})", tenantId.Value);
throw new AiUnavailableException(NoJsonAnswerText);
}
@@ -193,7 +167,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
?? throw new InvalidOperationException(
"GrpcAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
// CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1).
// tenantId: Id тенанта (формат N).
// ct: Токен отмены вызова.
// Возвращает: Опции вызова с заголовками, deadline и отменой.
@@ -203,7 +176,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
cancellationToken: ct);
// Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13:
// секреты/тела ответов не логируются и в текст не попадают).
// exception: Исключение RPC-вызова.
// Возвращает: Текст ошибки.
@@ -213,7 +185,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
return detail.Length > 0 ? detail : ServiceUnavailableText;
}
// Первые max кодовых точек строки (python-срез без разрыва суррогатных пар).
// text: Строка.
// max: Лимит.
// Возвращает: Усечённая строка.
@@ -11,49 +11,30 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта <see cref="IAiTools"/> к автономному ai-service (Ruling 9, план Task 15/18/19).
/// gRPC-адаптер порта <see cref="IAiTools"/> к автономному ai-service.
/// </summary>
/// <remarks>
/// Регистрируется вместо Local-реализации при <c>Services:Ai:UseLocal=false</c> (Ruling 6). Поведение 1:1 с
/// ai.proto GenerateKeywords/EvaluateFit и прототипом:
/// <list type="bullet">
/// <item><see cref="GenerateKeywordsAsync"/> — RPC GenerateKeywords (deadline 120 с): конфиг провайдера из
/// настроек, описание ≤4000 (discovery_routes L29); недоступность — мягкий {ok:false, keywords:[], error}
/// (Ruling 11: generate-keywords-эндпоинт отдаёт HTTP 200 {keywords: [], error});</item>
/// <item><see cref="EvaluateFitAsync"/> — RPC EvaluateFit: текст ≤4000 (discovery_eval L41) + описание и ключи
/// задачи; сбой — <see cref="AiUnavailableException"/> (воркер Discovery падает в эвристику, Ruling 10);
/// успех — {fit, reason} (потолок причины 200 задаёт сервис, _AI_REASON_LIMIT L43);</item>
/// <item>usage ответов списывается с бюджета тенанта в tenant_limits и копится в lifetime-KV aiTokenUsage
/// (<see cref="TokenUsageRecorder"/>, Ruling 3 этапа 7), как у классификатора.</item>
/// </list>
/// Каждый вызов несёт metadata tenant-id + service-token (<see cref="AiGrpcConnection"/>, Ruling 1). Scoped:
/// настройки провайдера читаются через scoped-хранилище тенанта (ISettingsStore), как GrpcAiClassifier.
/// Выключатель aiEnabled порт не читает — его отрабатывает вызывающий (воркер/эндпоинт Discovery, Ruling 10/11).
/// </remarks>
public sealed class GrpcAiTools : IAiTools
{
/// <summary>
/// Deadline RPC ai-service — 120 с (README контрактов: провайдер 90/60 с + ретраи 0.8/2 с).
/// Deadline RPC ai-service — 120 с
/// </summary>
public const int RpcDeadlineSeconds = 120;
/// <summary>
/// Лимит описания ниши generate-keywords (discovery_routes L29: обрезает до 4000).
/// Лимит описания ниши generate-keywords.
/// </summary>
public const int MaxDescriptionCodePoints = 4000;
/// <summary>
/// Лимит текста сообщения evaluate-fit (discovery_eval L41: _AI_TEXT_LIMIT=4000).
/// Лимит текста сообщения evaluate-fit.
/// </summary>
public const int MaxEvalTextCodePoints = 4000;
// Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен).
private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд";
// Причина по умолчанию при fit=true, если сервис причину не вернул (1:1 _AI_REASON_LIMIT L170).
private const string FitReasonDefault = "подходит";
// Причина по умолчанию при fit=false, если сервис причину не вернул (1:1 L170).
private const string NotFitReasonDefault = "не подходит";
private readonly ITenantContext _tenantContext;
@@ -112,7 +93,6 @@ public sealed class GrpcAiTools : IAiTools
}
catch (RpcException exception)
{
// Мягкая ошибка для UI (Ruling 11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200.
_logger.LogDebug(exception, "generate-keywords недоступен (тенант {TenantId})", tenantId.Value);
return new AiGenerateKeywordsResultDto(Ok: false, Keywords: Array.Empty<string>(), Error: ErrorText(exception));
}
@@ -160,7 +140,6 @@ public sealed class GrpcAiTools : IAiTools
}
catch (RpcException exception)
{
// Сбой ИИ-оценки не роняет оценку кандидата — воркер падает в эвристику (Ruling 10, python L191192).
_logger.LogDebug(exception, "evaluate-fit недоступен (тенант {TenantId})", tenantId.Value);
throw new AiUnavailableException(ErrorText(exception));
}
@@ -179,7 +158,6 @@ public sealed class GrpcAiTools : IAiTools
?? throw new InvalidOperationException(
"GrpcAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
// CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1).
// tenantId: Id тенанта (формат N).
// ct: Токен отмены вызова.
// Возвращает: Опции вызова с заголовками, deadline и отменой.
@@ -189,7 +167,6 @@ public sealed class GrpcAiTools : IAiTools
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
cancellationToken: ct);
// Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13:
// секреты/тела ответов не логируются и в текст не попадают).
// exception: Исключение RPC-вызова.
// Возвращает: Текст ошибки.
@@ -199,7 +176,6 @@ public sealed class GrpcAiTools : IAiTools
return detail.Length > 0 ? detail : ServiceUnavailableText;
}
// Первые max кодовых точек строки (python-срез без разрыва суррогатных пар).
// text: Строка.
// max: Лимит.
// Возвращает: Усечённая строка.
@@ -17,43 +17,22 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта IMlClient к автономному ml-service (Ruling 4/6, план Task 16 L438445).
/// gRPC-адаптер порта IMlClient к автономному ml-service.
/// </summary>
/// <remarks>
/// Регистрируется вместо Local-заглушки при <c>Services:Ml:UseLocal=false</c> (выбор на старте, Ruling 6).
/// Поведение 1:1 с <c>backend/app/services/ml_client.py</c> и ml.proto:
/// <list type="bullet">
/// <item><see cref="StatusAsync"/> — статус модели из ml-service (RPC Status, deadline 10 с) с кэшем 15 с
/// (<see cref="MlStatusCache"/>, python L3031/127135) + локальная статистика тенанта из KV/таблиц
/// (счётчики ml/ai, learning = count(CardMoves), outbox = count(MlOutbox)); сервис недоступен — старые
/// данные кэша (или «не готова») и <c>reachable=false</c>;</item>
/// <item><see cref="PredictAsync"/> — RPC Predict (deadline 5 с); сбой/недоступность → фиксированный «не
/// уверен» (python L101–107: решит ИИ/локальный путь воркера);</item>
/// <item><see cref="ResetAsync"/> — RPC Reset (deadline 10 с); при успехе — очистка своей очереди
/// MlOutbox (reset_model L110124) и инвалидация кэша статуса; сбой — мягкий <c>{ok:false,error}</c>,
/// очередь не трогается;</item>
/// <item><see cref="PushAsync"/> — ВСЕГДА запись в MlOutbox через <see cref="IMlLearningStore"/> (Ruling 6:
/// обучение гарантированно и локально; отправку батчами делает <c>MlOutboxFlushScheduler</c>);</item>
/// <item><see cref="TrainBatchAsync"/> (IMlTrainClient) — RPC TrainBatch (deadline 30 с) для фонового флашера.</item>
/// </list>
/// Каждый вызов несёт metadata tenant-id + service-token (<see cref="MlGrpcConnection"/>, Ruling 1). Scoped:
/// локальная статистика читает KV-настройки и таблицы тенанта (ISettingsStore/IMlLearningStore → scoped
/// TenantDbContext), как LocalMlClient.
/// </remarks>
public sealed class GrpcMlClient : IMlClient, IMlTrainClient
{
/// <summary>
/// Deadline Predict — 5 с (README контрактов: локальная модель).
/// Deadline Predict — 5 с
/// </summary>
public const int PredictDeadlineSeconds = 5;
/// <summary>
/// Deadline Status/Reset — 10 с (README контрактов).
/// Deadline Status/Reset — 10 с
/// </summary>
public const int StatusDeadlineSeconds = 10;
/// <summary>
/// Deadline TrainBatch — 30 с (README контрактов: батч ≤100, 1 транзакция).
/// Deadline TrainBatch — 30 с
/// </summary>
public const int TrainBatchDeadlineSeconds = 30;
@@ -78,7 +57,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
// Кэш статуса сервиса на тенанта (15 с).
private readonly MlStatusCache _statusCache;
// Recorder истории расхода (ML-событие расхода, этап 10, T2): оценка токенов входного текста.
private readonly TokenUsageRecorder _usageRecorder;
// Логгер сбоев вызовов ml-service.
@@ -92,7 +70,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
/// <param name="learningStore">Хранилище обучения ML (очередь MlOutbox + журнал).</param>
/// <param name="connection">Транспорт ml-service (singleton-канал + service-token).</param>
/// <param name="statusCache">Кэш статуса сервиса на тенанта (singleton).</param>
/// <param name="usageRecorder">Recorder истории расхода (ML-событие predict, этап 10, T2).</param>
/// <param name="usageRecorder">Recorder истории расхода.</param>
/// <param name="logger">Логгер сбоев.</param>
public GrpcMlClient(
ITenantContext tenantContext,
@@ -155,14 +133,12 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
new PredictRequest { Text = text ?? string.Empty },
CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), ct));
// История расхода (этап 10, T2): ML-ответ токенов не несёт — оценка входного текста (≈chars/4),
// бюджет/lifetime AI-счётчик не затрагиваются (локальная модель бесплатна).
await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct);
return MapPredict(reply);
}
catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException)
{
// Сервис недоступен/таймаут/отмена — «не уверен» (python predict L101107): решит ИИ/локальный путь.
_logger.LogDebug(exception, "ML predict недоступен (тенант {TenantId})", tenantId.Value);
return NotReadyPrediction;
}
@@ -182,7 +158,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
}
catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException)
{
// Мягкая ошибка реального сервиса (python reset_model L117121): ok:false + текст; outbox не трогаем.
_logger.LogWarning(exception, "ML reset не удался (тенант {TenantId})", tenantId.Value);
return new MlResetResultDto(Ok: false, Error: ErrorText(exception));
}
@@ -192,7 +167,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
return new MlResetResultDto(Ok: false, Error: reply.HasError ? reply.Error : DefaultResetError);
}
// 1:1 reset_model L122123: после успешного сброса сервиса — очистка своей очереди + свежий статус.
await _learningStore.ClearOutboxAsync(ct);
_statusCache.Invalidate(tenantId.Value);
return new MlResetResultDto(Ok: true, Error: null);
@@ -205,8 +179,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
double delta,
CancellationToken ct)
{
// Обучение гарантированно и локально (Ruling 6): сигнал всегда пишется в MlOutbox, отправку батчами
// делает MlOutboxFlushScheduler — и в Local-, и в gRPC-режиме (ml_client.py L67).
await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct);
}
@@ -251,7 +223,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
return fresh;
}
// Последние известные данные (при сбое refresh останутся они — python refresh_status L132135).
_statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot stale);
MlServiceStatusDto previous = stale?.Service ?? NotReadyServiceStatus;
@@ -275,7 +246,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
}
}
// Маппит ответ Status в контрактный статус модели (поля 1:1 с MlServiceStatusDto).
// reply: Ответ ml-service.
// Возвращает: DTO статуса модели.
private static MlServiceStatusDto MapStatus(StatusReply reply)
@@ -290,7 +260,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
Accuracy: reply.Eval?.Accuracy ?? 0.0));
}
// Маппит ответ Predict в контрактный результат (поля 1:1 с MlPredictResultDto).
// reply: Ответ ml-service.
// Возвращает: DTO предсказания.
private static MlPredictResultDto MapPredict(PredictReply reply)
@@ -320,7 +289,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
Margin: decision.Margin);
}
// CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1).
// tenantId: Id тенанта (формат N).
// deadline: Лимит времени вызова.
// ct: Токен отмены вызова.
@@ -342,7 +310,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
? "ML-сервис недоступен"
: "ML-сервис не ответил — повторите попытку через несколько секунд";
// Фиксированный ответ неготовой/недоступной модели: «не уверен» (Ruling 5, ml.proto L2123).
private static MlPredictResultDto NotReadyPrediction => new(
Take: false,
Label: null,
@@ -360,7 +327,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
Learned: 0,
Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0));
// Читает выключатель mlEnabled: «не false» (ml_routes.py L71) — false только при сохранённом JSON-false.
// ct: Токен отмены.
// Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true.
private async Task<bool> ReadMlEnabledAsync(CancellationToken ct)
@@ -10,44 +10,27 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта <see cref="ITelegramGateway"/> к автономному telegram-service (Ruling 6/7, план Task 14).
/// gRPC-адаптер порта <see cref="ITelegramGateway"/> к автономному telegram-service.
/// </summary>
/// <remarks>
/// Регистрируется вместо Local-заглушки при <c>Services:Telegram:UseLocal=false</c> (выбор на старте, Ruling 6).
/// Каждый RPC telegram.proto (TelegramService) маппится 1:1 в метод порта: подключение/отключение аккаунта
/// (StartPhone/StartQr/SendCode/SendPassword/Logout), каталог диалогов (RefreshDialogs), мониторинг
/// (SetMonitor/SetMonitorAll), backfill (Backfill), превью (ReadRecent) и discovery-операции (Search/GetInfo/
/// ReadForEval/Join/Leave). Каждый вызов несёт metadata tenant-id + service-token
/// (<see cref="TelegramGrpcConnection"/>, Ruling 1) и deadline по README контрактов (src/contracts L6274).
/// <para>
/// Ошибки домена telegram-service приходят RPC-статусами с каноническими detail («Telegram не подключён»,
/// «Сначала сохраните Telegram api_id и api_hash в настройках», «Неверный код», …) — RpcException
/// пробрасывается наружу без изменений, текст причины решает HTTP-слой эндпоинтов (Ruling 7/8). Транспортные
/// сбои (сервис недоступен/таймаут) нормализуются в RpcException Unavailable с detail «Telegram не подключён»
/// — ветки эндпоинтов отвечают «не подключён», как при недоступном сервисе.
/// </para>
/// </remarks>
public sealed class GrpcTelegramClient : ITelegramGateway
{
/// <summary>
/// Deadline локальных команд статуса/зеркала — 10 с (README L66).
/// Deadline локальных команд статуса/зеркала — 10 с.
/// </summary>
public const int ShortDeadlineSeconds = 10;
/// <summary>
/// Deadline сетевых команд Telegram — 60 с (README L67: паузы анти-бана внутри сервиса).
/// Deadline сетевых команд Telegram — 60 с.
/// </summary>
public const int CommandDeadlineSeconds = 60;
/// <summary>
/// Deadline тяжёлых команд каталога/backfill — 120 с (README L68: iter_dialogs 500, backfill).
/// Deadline тяжёлых команд каталога/backfill — 120 с.
/// </summary>
public const int LongDeadlineSeconds = 120;
// Detail недоступного telegram-service (Ruling 7: «недоступность сервиса → не подключён»).
private const string NotConnectedDetail = "Telegram не подключён";
// Контекст текущего тенанта (id — в metadata вызовов, Ruling 1).
private readonly ITenantContext _tenantContext;
// Транспорт gRPC telegram-service (канал + metadata).
@@ -415,10 +398,8 @@ public sealed class GrpcTelegramClient : ITelegramGateway
?? throw new InvalidOperationException(
"GrpcTelegramClient запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
// Выполняет unary RPC с metadata tenant-id/service-token, deadline и токеном отмены (Ruling 1).
// TReply: Тип ответа RPC.
// tenantId: Id тенанта (формат N).
// deadline: Лимит времени вызова (README контрактов L6274).
// ct: Токен отмены вызова.
// call: Вызов клиента (принимает клиент и CallOptions).
// Возвращает: Ответ RPC.
@@ -440,7 +421,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
// Нормализует транспортные сбои в RpcException «Telegram не подключён»; RpcException домена — как есть.
// Отмена по токену вызывающего пробрасывается без нормализации (не сбой сервиса). Доменные
// RPC-ошибки (INVALID_ARGUMENT/FAILED_PRECONDITION/…) несут канонический detail — их трогать нельзя:
// текст причины 1:1 уходит в {detail} эндпоинтов (Ruling 7/8).
// exception: Исключение вызова.
// tenantId: Id тенанта (лог).
// operation: Имя RPC (лог-аудит).
@@ -457,7 +437,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
// Доменная RPC-ошибка сервиса (INVALID_ARGUMENT/FAILED_PRECONDITION/NOT_FOUND…) несёт канонический
// detail (Ruling 1) — пробрасываем без изменений, текст причины 1:1 уходит в {detail} эндпоинтов.
// Unavailable с detail (сервис сам ответил причиной) — тоже как есть.
if (exception is RpcException rpc &&
(rpc.StatusCode != StatusCode.Unavailable || !string.IsNullOrEmpty(rpc.Status.Detail)))
@@ -469,7 +448,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
return new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail));
}
// Маппит записи каталога proto (DialogEntry) в контрактный DTO каталога/поиска (Ruling 7).
// entries: Записи каталога telegram-service.
// Возвращает: Записи в форме контракта (username → handle).
private static IReadOnlyList<TelegramDialogEntryDto> MapEntries(Google.Protobuf.Collections.RepeatedField<DialogEntry> entries)
@@ -7,31 +7,14 @@ using Deal.Modules.Pipeline.Application.Services;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Локальная реализация <see cref="IAiClassifier"/> без внешнего ИИ-сервиса (Ruling 5, план Task 6 L370388).
/// Локальная реализация <see cref="IAiClassifier"/> без внешнего ИИ-сервиса.
/// </summary>
/// <remarks>
/// Адаптер поверх чистого ядра разбора модуля Pipeline (эталон — <see cref="LocalColumnSuggester"/>: ядро
/// владельца + тонкий адаптер): <see cref="ClassifyAsync"/> разбирает сообщение <see cref="LocalFieldsParser"/>
/// (pipeline.py _local_fields L718798 — заголовок, суть, стек/грейд/бюджет/контакты по меткам и fallback,
/// is_vacancy по hire-маркерам) и маппит в контрактный <see cref="AiParsedCardDto"/> через
/// <see cref="AiCardMapper.FromLocal"/> (Ruling 7 — модульный маппинг используют и локальные пути воркера):
/// бюджет нормализуется, контакты квалифицируются, блок «О заявке» заполняет только
/// legacy-суть, тип — маркерная гипотеза: is_vacancy_known=false, board=null («смысловые колонки до ИИ не
/// назначаем», python L954–958; карточку в колонку кладёт воркер после ContainerAccepts). Фильтр всегда
/// <c>{pass:true, skipped:true}</c> — реального ИИ-фильтра нет, а выключатель aiFilterEnabled порт не читает
/// (ветки выключателя отрабатывает воркер, как filter_incoming L190192 и L11031106). На этапе 6 адаптер
/// заменяется gRPC-клиентом ai-service с тем же контрактом. Scoped: LocalFieldsParser читает KV-настройки
/// тенанта (ISettingsStore → scoped TenantDbContext запроса).
/// </remarks>
/// <param name="fieldsParser">Локальный структуратор модуля Pipeline (маркеры hireMarkers/levelTerms — из настроек).</param>
public sealed class LocalAiClassifier(LocalFieldsParser fieldsParser) : IAiClassifier
{
/// <inheritdoc />
public Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
{
// Реального ИИ-фильтра нет (Ruling 5): локальная реализация всегда пропускает. Семантика ответа 1:1
// с ветками прототипа, где фильтр недоступен/выключен: {pass:true, reason:null, skipped:true}
// (filter_incoming L190198, сбой L11031106). Отсевы spam_ai/filter_ai станут достижимы этапом 6.
return Task.FromResult(new AiFilterResultDto(Pass: true, Reason: null, Skipped: true));
}
@@ -4,16 +4,8 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Локальная реализация <see cref="IAiTools"/> без внешнего ИИ-сервиса (Ruling 9, план Task 15).
/// Локальная реализация <see cref="IAiTools"/> без внешнего ИИ-сервиса.
/// </summary>
/// <remarks>
/// Регистрируется при <c>Services:Ai:UseLocal=true</c> (default, Ruling 6). Методы НЕ поддерживаются — на
/// этапе 6 локальной генерации ключей/оценки fit нет: Discovery-воркер сам выбирает эвристику (при
/// aiEnabled=false или сбое, python discovery_eval L186194), а generate-keywords-эндпоинт (Task 19) ловит
/// исключение и отдаёт мягкую ошибку {keywords: [], error} (Ruling 11). NotSupportedException — явный сигнал
/// «вызов порта в локальном режиме — ошибка сценария», чтобы будущий потребитель (Discovery) не получил
/// молча пустые ключи/ложный fit. Scoped-зависимостей нет (экземпляр лёгкий, как LocalAiClassifier на дефолты).
/// </remarks>
public sealed class LocalAiTools : IAiTools
{
// Сообщение исключения методов (локальный режим = ai-service не подключён).
@@ -15,51 +15,30 @@ using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRule
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика этапа 3 (Ruling 3, план Task 14).
/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика.
/// </summary>
/// <remarks>
/// Реализует порт <see cref="IColumnSuggester"/> поверх порта <see cref="ICardStore"/> и чистого ядра
/// <see cref="SuggestHeuristics"/> (модуль Kanban): читает «Неразобранное» (ListInboxWithSourceAsync),
/// считает группы слов-тем и создаёт доски suggested=true (RulesJson {mode:"any", keywords:[…]},
/// note-обоснование, цвет/позицию даёт ContainersService) и раскладывает карточки (is_new=TRUE,
/// prev_col='inbox', matchHits по правилам доски — Ruling 2). Причины отказов — детерминированные
/// строки прототипа/Ruling 3: «мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже
/// есть или нечего сгруппировать»; кулдаун повторов — KV-ключ <see cref="SettingsKeys.LastSuggestAt"/>
/// (прототип COOLDOWN_S L51 + «недавно предлагали — подождите» L95). Журнал CardMoves/ML-сигналы при
/// раскладке НЕ пишутся (suggest.py _assign_ids L220239 — это не действие пользователя, а предложение).
/// Suggest-keywords читает карточки вне trash/archive (suggest_domain_keywords L172178).
/// </remarks>
/// <param name="store">Порт хранилища (карточки «Неразобранного», переносы в колонки-доски).</param>
/// <param name="settings">KV-хранилище настроек тенанта (кулдаун lastSuggestAt, как KEY suggest.py L52).</param>
/// <param name="settings">KV-хранилище настроек тенанта.</param>
/// <param name="containersService">Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами.</param>
public sealed class LocalColumnSuggester(
ICardStore store,
ISettingsStore settings,
ContainersService containersService) : IColumnSuggester
{
// ── Кулдаун повторов (suggest.py COOLDOWN_S L51; KEY lastSuggestAt L52) ──
// Как часто можно переспрашивать ИИ-предложения: 20 минут (COOLDOWN_S = 20 * 60, L51).
private const long CooldownSeconds = 20 * 60;
// ── Детерминированные причины (Ruling 3; строки прототипа suggest.py) ──
// Кулдаун: повторный вызов слишком рано (suggest.py L95 «недавно предлагали — подождите»).
private const string CooldownReason = "недавно предлагали — подождите";
// Мало карточек в «Неразобранном»: {0} — порог MIN_INBOX (suggest.py L102).
private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})";
// Групп не вышло: темы похожи на существующие доски или карточкам нечего разделить (L159).
private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать";
// Мало карточек для ключей: нужно хотя бы 3 (suggest_domain_keywords L178).
private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)";
// Повторяющихся слов-маркеров не нашлось (suggest_domain_keywords L187, текст прототипа).
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
// Режим правил колонки-предложения: «любое из условий» (suggest.py _rules_for L68 mode: any).
private const string RulesModeAny = "any";
/// <inheritdoc />
@@ -80,7 +59,6 @@ ContainersService containersService) : IColumnSuggester
Cooldown: false);
}
// Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138139).
IReadOnlyList<ContainerDto> containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct);
IReadOnlyList<string> existingNames = containers
.Where(container => !container.Suggested)
@@ -96,7 +74,6 @@ ContainersService containersService) : IColumnSuggester
int created = await StoreSuggestedColumnsAsync(inbox, plans, ct);
if (created == 0)
{
// Все колонки откатаны: карточки групп разобраны между чтением и раскладкой (suggest.py L153156).
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
}
@@ -107,7 +84,6 @@ ContainersService containersService) : IColumnSuggester
/// <inheritdoc />
public async Task<SuggestKeywordsResultDto> SuggestKeywordsAsync(CancellationToken ct)
{
// Выборка ключей — как suggest_domain_keywords L172176: карточки вне trash/archive с текстом,
// свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC).
IReadOnlyList<CardDto> cards = await store.ListCardsAsync(new CardsQuery(null), ct);
List<string> texts = cards
@@ -131,22 +107,17 @@ ContainersService containersService) : IColumnSuggester
return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null);
}
// Создаёт доски-предложения по планам и раскладывает карточки (suggest.py L129156).
// inbox: Снимок «Неразобранного» (карточки планов берутся из него).
// plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4).
// ct: Токен отмены.
// Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек).
// Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием.
// Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и
// раскладкой (пользователь/тик), пропускаются — 1:1 со страховкой _assign_ids L231233. Если в
// колонку не легло ни одной карточки, пустая доска-предложение откатывается (_rollback_suggested
// L242248). matchHits считаются по правилам созданной доски (Ruling 2); журнал/ML не пишутся.
private async Task<int> StoreSuggestedColumnsAsync(
IReadOnlyList<CardDto> inbox,
IReadOnlyList<SuggestedColumnPlan> plans,
CancellationToken ct)
{
// Свежий снимок inbox — страховка «карточку уже разобрали» (suggest.py _assign_ids L231233).
HashSet<string> inboxIds = (await store.ListInboxWithSourceAsync(ct))
.Select(card => card.Id)
.ToHashSet(StringComparer.Ordinal);
@@ -195,7 +166,6 @@ ContainersService containersService) : IColumnSuggester
if (placed == 0)
{
// Ничего не легло — пустое предложение не нужно (suggest.py L152156).
await containersService.DeleteAsync(container.Id, ct);
continue;
}
@@ -207,8 +177,6 @@ ContainersService containersService) : IColumnSuggester
}
// Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут.
// Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение
// пишется только после успеха, L160–161; битый KV — дефолт «никогда»).
// ct: Токен отмены.
// Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}).
private async Task<bool> WithinCooldownAsync(CancellationToken ct)
@@ -236,7 +204,6 @@ ContainersService containersService) : IColumnSuggester
}
}
// Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())).
// ct: Токен отмены.
private Task WriteLastSuggestAtAsync(CancellationToken ct) =>
settings.SetAsync(
@@ -8,36 +8,19 @@ using Deal.Modules.Settings.Application.Models;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Локальная реализация <see cref="IMlClient"/> без внешнего ML-сервиса (Ruling 4, план Task 5 L266286).
/// Локальная реализация <see cref="IMlClient"/> без внешнего ML-сервиса.
/// </summary>
/// <remarks>
/// Этап 3: обучение копится локально в очередь MlOutbox (отправка в ML-сервис — фоновый воркер
/// этапа 6), счётчики learning/outbox читаются из таблиц схемы тенанта (Ruling 4). Поведение 1:1
/// с <c>backend/app/services/ml_client.py</c>: <c>PushAsync</c> = push L4049 (trim text/label,
/// пустые — no-op, text[:6000], id <c>mle_</c>+12 hex); <c>StatusAsync</c> = snapshot L138150
/// (learning = count(CardMoves), outbox = count(MlOutbox), ml/ai — KV-счётчики решений, на этапе 3
/// всегда 0 — не инкрементируются); <c>ResetAsync</c> = reset_model L110124 (чистится только
/// MlOutbox, журнал и KV не трогаются). Модель «не готова» до этапа 4 (ready=false, classes пусты,
/// learned=0, eval обнулён), предсказание — фиксированный «не уверен» (Ruling 5 L79–80); заглушка
/// «жива»: reachable=true. Зависимости — порты (ISettingsStore, IMlLearningStore), а не EF:
/// LocalMlClient остаётся unit-чистым (план Task 5). На этапе 6 адаптер заменяется gRPC-клиентом
/// с тем же контрактом (Ruling 4 L7374).
/// </remarks>
/// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param>
/// <param name="learningStore">Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves.</param>
public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learningStore) : IMlClient
{
// Пустой словарь классов модели (неготовая модель, Ruling 5).
private static readonly IReadOnlyDictionary<string, double> EmptyClasses = new Dictionary<string, double>();
// Пустой словарь весов предсказания (неготовая модель, Ruling 5).
private static readonly IReadOnlyDictionary<string, double> EmptyScores = new Dictionary<string, double>();
/// <inheritdoc />
public async Task<MlStatusResponseDto> StatusAsync(CancellationToken ct)
{
// Статус самой модели: обучение копится в outbox, реальная модель появится этапом 4 —
// сейчас модель всегда не готова (Ruling 5).
var service = new MlServiceStatusDto(
Ready: false,
Classes: EmptyClasses,
@@ -48,9 +31,6 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin
int mlDecisions = await ReadCounterAsync(SettingsKeys.MlDecisions, ct);
int aiDecisions = await ReadCounterAsync(SettingsKeys.AiDecisions, ct);
// Локальная статистика (ml_client.snapshot L138150): learning = count(CardMoves),
// outbox = count(MlOutbox) (Ruling 4); ml/ai — KV-счётчики РЕШЕНИЙ пайплайна (этап 4):
// на этапе 3 не инкрементируются и всегда 0.
int learning = await learningStore.CountLearningAsync(ct);
int outbox = await learningStore.CountOutboxAsync(ct);
@@ -70,7 +50,6 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin
/// <inheritdoc />
public Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct)
{
// Неготовая модель ничего не решает (Ruling 5 L79–80) — текст не влияет на ответ.
return Task.FromResult(new MlPredictResultDto(
Take: false,
Label: null,
@@ -85,8 +64,6 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin
/// <inheritdoc />
public async Task<MlResetResultDto> ResetAsync(CancellationToken ct)
{
// Сброс 1:1 с reset_model (L110124): чистится только очередь обучения MlOutbox; журнал
// CardMoves и KV-счётчики не трогаются (Ruling 4, план L275276). Реального сервиса нет — ok.
await learningStore.ClearOutboxAsync(ct);
return new MlResetResultDto(Ok: true, Error: null);
}
@@ -98,13 +75,10 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin
double delta,
CancellationToken ct)
{
// Обучение гарантированно и локально (ml_client.push L4049): действие пользователя — строка
// очереди MlOutbox (отправку в ML-сервис делает воркер этапа 6). Общая логика (trim text/label,
// пустые — тихий no-op, text[:6000], id mle_+hex) — в MlOutboxQueue, общем для Local/Grpc-адаптеров.
await MlOutboxQueue.PushAsync(learningStore, text, label, delta, ct);
}
// Читает выключатель mlEnabled: «не false» (ml_routes.py L71) — false только при сохранённом JSON-false.
// ct: Токен отмены.
// Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true.
private async Task<bool> ReadMlEnabledAsync(CancellationToken ct)
@@ -4,18 +4,8 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Локальная заглушка <see cref="ITelegramGateway"/> без telegram-service (Ruling 6, план Task 13/14).
/// Локальная заглушка <see cref="ITelegramGateway"/> без telegram-service.
/// </summary>
/// <remarks>
/// Регистрируется как дефолт dev (до появления gRPC-клиента GrpcTelegramClient под флагом
/// Services:Telegram:UseLocal=false — Ruling 6): реальный telegram-service в dev не поднят, поэтому гейт
/// нейтрален — статус «idle/не подключён» (1:1 форма «сервис недоступен → idle-форма», Ruling 8), команды —
/// no-op, выборки пусты. На этапе 6 (Task 14 curl-приёмка) эндпоинты тестируются фейк-реализацией гейта в
/// тестах (не этой заглушкой); заглушка гарантирует разрешимость графа DI до подключения сервиса.
/// Команды подключения (StartPhone/StartQr/SendCode/SendPassword/Logout) и discovery-операции (Search/Info/
/// ReadForEval/Join/Leave) без сервиса не имеют смысла — их ветки эндпоинтов/воркера отдают ошибку
/// «Telegram не подключён» по статусу подключения (Ruling 7), сам гейт их не вызывает.
/// </remarks>
public sealed class LocalTelegramGateway : ITelegramGateway
{
// Фаза idle-формы (аккаунт не подключён — сервиса нет).
@@ -24,7 +14,6 @@ public sealed class LocalTelegramGateway : ITelegramGateway
/// <inheritdoc />
public Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct)
{
// «Сервис недоступен → idle-форма» (Ruling 8): connected=false, live-поля пусты.
return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null));
}
@@ -4,28 +4,21 @@ using Deal.Modules.Kanban.Application.Models;
namespace Deal.Infrastructure.Integrations.Services;
// Общая запись обучающего сигнала в очередь MlOutbox (ml_client.push L4049) для адаптеров IMlClient.
// Поведение 1:1 с прототипом и с LocalMlClient.PushAsync этапа 3: пустые после trim text/label —
// тихий no-op, text обрезается до 6000 символов (без разрыва суррогатной пары), id — mle_ +
// 12 случайных hex (store.uid L48). Обучение идёт ВСЕГДА (выключатель mlEnabled его не трогает) —
// и в Local-, и в gRPC-режиме сигнал сначала пишется в outbox, отправку в ml-service делает фоновый
// MlOutboxFlushScheduler (Ruling 6: PushAsync ВСЕГДА пишет MlOutbox).
internal static class MlOutboxQueue
{
// Максимальная длина текста обучающего примера (ml_client.push L48: text[:6000]).
internal const int MaxLearningTextLength = 6000;
// Случайный хвост id outbox: 6 байт → 12 hex-символов (прототип store.uid — uuid4().hex[:12]).
private const int OutboxIdRandomBytes = 6;
/// <summary>
/// Пишет строку очереди обучения: trim text/label (пустые — no-op), text[:6000], id mle_+hex.
/// Пишет строку очереди обучения
/// </summary>
/// <param name="learningStore">Хранилище обучения (таблица MlOutbox схемы тенанта).</param>
/// <param name="text">Текст обучающего примера (source_msg карточки или title).</param>
/// <param name="label">Метка: id доски (<c>b_...</c>), <c>spam</c> либо <c>t:hire|t:order</c>.</param>
/// <param name="delta">Вес сигнала (1.0 — учить, −1.0 — снять метку).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Задача завершается после записи строки (отправку делает фоновый флашер).</returns>
public static async Task PushAsync(
IMlLearningStore learningStore,
@@ -49,7 +42,6 @@ internal static class MlOutboxQueue
ct);
}
// Генерирует id строки outbox: префикс mle_ + 12 случайных hex-символов (прототип store.uid).
// Возвращает: Короткий id записи очереди.
private static string NewOutboxId()
=> KanbanIdPrefixes.MlOutbox + Convert.ToHexString(RandomNumberGenerator.GetBytes(OutboxIdRandomBytes)).ToLowerInvariant();
@@ -57,7 +49,6 @@ internal static class MlOutboxQueue
// Обрезает текст до MaxLearningTextLength символов, не разбивая суррогатную пару на конце.
// text: Текст (уже trim-нут).
// Возвращает: Первые 6000 символов (или весь текст, если короче).
// .NET-срез идёт по UTF-16-единицам и может разбить суррогатную пару; Python-срез прототипа
// (text[:6000]) режет по code points — хвостовой high-surrogate убираем, чтобы в БД не ушла «битая» пара.
private static string TruncateText(string text)
{
@@ -4,18 +4,12 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Кэш статуса ML-сервиса на тенанта (python ml_client L3031 + refresh_status L127135; Ruling 6).
/// Кэш статуса ML-сервиса на тенанта.
/// </summary>
/// <remarks>
/// Кэш живёт 15 секунд и хранит последний известный статус + флаг <c>reachable</c>: обновление происходит
/// при вызове <c>GrpcMlClient.StatusAsync</c>, когда запись устарела/отсутствует; при сбое сервиса строка
/// остаётся со старыми данными и <c>reachable=false</c> (python L132135). Singleton: кэш переживает scope
/// запросов (в /api/ml/status и фоновых циклах тенант один и тот же), ключ — id тенанта (формат N).
/// </remarks>
public sealed class MlStatusCache
{
/// <summary>
/// Время жизни кэша статуса сервиса — 15 с (refresh_status python L3031).
/// Время жизни кэша статуса сервиса — 15 с.
/// </summary>
public const int CacheTtlSeconds = 15;
@@ -33,7 +27,7 @@ public sealed class MlStatusCache
private readonly Func<DateTimeOffset> _utcNow;
/// <summary>
/// Создаёт кэш с системными часами (DateTimeOffset.UtcNow).
/// Создаёт кэш с системными часами
/// </summary>
public MlStatusCache()
: this(() => DateTimeOffset.UtcNow)
@@ -41,7 +35,7 @@ public sealed class MlStatusCache
}
/// <summary>
/// Создаёт кэш с заданными часами (тесты TTL 15 с).
/// Создаёт кэш с заданными часами
/// </summary>
/// <param name="utcNow">Источник текущего времени (UTC).</param>
public MlStatusCache(Func<DateTimeOffset> utcNow)
@@ -51,7 +45,7 @@ public sealed class MlStatusCache
}
/// <summary>
/// Возвращает свежую запись кэша (возраст ≤ <see cref="CacheTtlSeconds"/>).
/// Возвращает свежую запись кэша
/// </summary>
/// <param name="tenantId">Id тенанта (формат N).</param>
/// <param name="snapshot">Свежая запись (если есть).</param>
@@ -73,7 +67,7 @@ public sealed class MlStatusCache
}
/// <summary>
/// Возвращает последнюю запись независимо от возраста (для «старые данные при сбое», python L134).
/// Возвращает последнюю запись независимо от возраста.
/// </summary>
/// <param name="tenantId">Id тенанта (формат N).</param>
/// <param name="snapshot">Последняя запись (если есть).</param>
@@ -81,7 +75,7 @@ public sealed class MlStatusCache
public bool TryGet(string tenantId, out Snapshot snapshot) => _entries.TryGetValue(tenantId, out snapshot!);
/// <summary>
/// Сохраняет запись статуса (момент обновления — сейчас).
/// Сохраняет запись статуса
/// </summary>
/// <param name="tenantId">Id тенанта (формат N).</param>
/// <param name="service">Статус модели.</param>
@@ -95,7 +89,7 @@ public sealed class MlStatusCache
}
/// <summary>
/// Помечает запись устаревшей (сброс модели, python reset_model L123 — refresh после сброса).
/// Помечает запись устаревшей.
/// </summary>
/// <param name="tenantId">Id тенанта (формат N).</param>
public void Invalidate(string tenantId) => _entries.TryRemove(tenantId, out _);
@@ -7,41 +7,30 @@ using Grpc.Net.Client;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Health-проба grpc.health.v1 автономных сервисов (ml/ai/telegram) для операторского health
/// (план Task 10: GET /api/operator/health, Ruling 3/6/9).
/// Health-проба grpc.health.v1 автономных сервисов
/// </summary>
/// <remarks>
/// Каждый вызов строит свой короткоживущий канал к <c>endpoint</c> сервиса (dev — без TLS, Ruling 2;
/// mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским сертификатом и
/// проверяет CA сервера — сертификаты передаются <see cref="MtlsCertificates"/> в конструктор) и спрашивает
/// Health.Check("") с дедлайном 3 с — health не должен висеть дольше таймаута. Классификация: ответ
/// <c>SERVING</c> → Reachable+Serving; ответ с иным статусом → Reachable без
/// Serving; таймаут/нет соединения (Unavailable/DeadlineExceeded, HTTP-транспорт) и сервис без health-контракта
/// (Unimplemented) → <see cref="ServiceHealthResult.Unreachable"/>.
/// </remarks>
public sealed class ServiceHealthProbe
{
/// <summary>
/// Дедлайн health-RPC, секунд (Ruling 3/9: операторский health отвечает за ~3 с на сервис).
/// Дедлайн health-RPC, секунд.
/// </summary>
public const int HealthTimeoutSeconds = 3;
private readonly MtlsCertificates? _mtlsCertificates;
/// <summary>
/// Создаёт пробу; mTLS-каналы — при переданных сертификатах (иначе plaintext, dev).
/// Создаёт пробу; mTLS-каналы — при переданных сертификатах
/// </summary>
/// <param name="mtlsCertificates">Сертификаты mTLS (Ruling 6, Task 13): null — plaintext-канал.</param>
/// <param name="mtlsCertificates">Сертификаты mTLS: null — plaintext-канал.</param>
public ServiceHealthProbe(MtlsCertificates? mtlsCertificates = null)
{
_mtlsCertificates = mtlsCertificates;
}
/// <summary>
/// Проверяет health-контракт gRPC-сервиса по базовому адресу (grpc.health.v1, сервис "").
/// Проверяет health-контракт gRPC-сервиса по базовому адресу
/// </summary>
/// <param name="endpoint">Базовый адрес сервиса (http://host:port; пустой/пробельный — ошибка аргумента).</param>
/// <param name="ct">Токен отмены вызывающего.</param>
/// <returns>Результат пробы (см. <see cref="ServiceHealthResult"/>).</returns>
public async Task<ServiceHealthResult> ProbeAsync(string endpoint, CancellationToken ct)
{
@@ -13,36 +13,16 @@ using Deal.SharedKernel.Tenants.Models;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Recorder расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): успешный RPC ai-service
/// (Filter/Classify/GenerateKeywords/EvaluateFit) списывает usage с бюджета тенанта, копит lifetime-сумму
/// в tenant-KV и пишет событие в public.token_usage_events; локальный ML-вызов пишет событие (kind=ml).
/// Recorder расхода токенов
/// </summary>
/// <remarks>
/// Точка вызова — та же, что у этапа 6 (GrpcAiClassifier/GrpcAiTools после успешного RPC; ML — GrpcMlClient/
/// LocalMlClient.Predict). Три учёта:
/// <list type="number">
/// <item><b>Бюджет периода</b> — <c>ITenantLimitStore.AddUsageAsync</c>: инкремент UsedTokens в public.tenant_limits
/// (тот же scoped DealDbContext запроса) с ленивым reset периода; источник истины бюджетного гейта Task 9.
/// Только для платных AI-вызовов (ML бюджет не расходует).</item>
/// <item><b>Lifetime-счётчик</b> — tenant-KV ключ aiTokenUsage ({prompt, completion, total}, существующий формат
/// этапа 6): «всего» за всё время. Только для AI (ML — локальный, aiTokenUsage не засоряет).</item>
/// <item><b>История событий</b> — <c>TokenUsageEventService.AppendAsync</c> (public.token_usage_events): провайдер,
/// модель, вид (ai|ml), токены; основа time-series аналитики оператора (этап 10, T3).</item>
/// </list>
/// Списание в tenant_limits выполняется только при Total&gt;0 (нулевой usage ответа моделью не заводит строку
/// лимита); lifetime-KV пишется всегда, как раньше. Scoped: пишет в KV-хранилище тенанта запроса (ISettingsStore
/// → scoped TenantDbContext), в public.tenant_limits/токен-историю — через scoped DealDbContext.
/// </remarks>
public sealed class TokenUsageRecorder
{
// Имена полей значения aiTokenUsage (1:1 с Usage ai.proto: prompt/completion/total).
private const string PromptField = "prompt";
private const string CompletionField = "completion";
private const string TotalField = "total";
// Оценка токенов по символам, символов на токен (конвенция проекта ai.proto Ruling 5: ≈chars/4).
private const int CharsPerToken = 4;
private readonly ISettingsStore _store;
@@ -56,7 +36,7 @@ public sealed class TokenUsageRecorder
/// <param name="store">KV-хранилище настроек тенанта (ключ aiTokenUsage, lifetime-счётчик).</param>
/// <param name="tenantLimits">Хранилище лимитов бюджета (public.tenant_limits, списание периода).</param>
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal; tenantId списания/события).</param>
/// <param name="events">Сервис истории расхода (public.token_usage_events, этап 10).</param>
/// <param name="events">Сервис истории расхода.</param>
public TokenUsageRecorder(
ISettingsStore store,
ITenantLimitStore tenantLimits,
@@ -74,13 +54,11 @@ public sealed class TokenUsageRecorder
}
/// <summary>
/// Списывает usage ответа ai-service: (1) инкремент бюджета периода в tenant_limits, (2) lifetime-сумму
/// в KV aiTokenUsage, (3) событие истории (kind=ai). usage null — no-op (успешный RPC без оценки токенов).
/// Списывает usage ответа ai-service
/// </summary>
/// <param name="usage">Оценка токенов ответа (Usage ai.proto; reply без usage — нули; null — no-op).</param>
/// <param name="provider">Id активного провайдера (deepseek/openai/anthropic/…; событие истории).</param>
/// <param name="model">Модель провайдера (событие истории).</param>
/// <param name="ct">Токен отмены.</param>
public async Task AddAsync(
Usage? usage,
string provider,
@@ -98,7 +76,6 @@ public sealed class TokenUsageRecorder
}
await AddToLifetimeAsync(usage, ct);
// Прикладная метрика (этап 12, пакет A): счётчик вызовов/токенов ИИ — та же точка, что и событие
// token_usage_events (без tenantId в метках).
DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion);
await RecordEventAsync(
@@ -112,13 +89,11 @@ public sealed class TokenUsageRecorder
}
/// <summary>
/// Записывает событие локального ML-вызова (kind=ml) с оценкой токенов по длине входного текста
/// (≈chars/4, конвенция ai.proto): бюджет/lifetime aiTokenUsage ML не затрагивает.
/// Записывает событие локального ML-вызова
/// </summary>
/// <param name="text">Входной текст предсказания (оценка токенов запроса; null — 0).</param>
/// <param name="provider">Провайдер/источник события (для локальной ML-модели — "local").</param>
/// <param name="model">Модель/вид локального ML-вызова (событие истории).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Оценка токенов (для тестов/наблюдаемости).</returns>
public async Task<long> AddEstimatedAsync(
string? text,
@@ -127,7 +102,6 @@ public sealed class TokenUsageRecorder
CancellationToken ct)
{
long promptTokens = EstimateTokens(text);
// Прикладная метрика (этап 12, пакет A): вызов локального ML + оценка токенов (та же точка,
// что и событие token_usage_events, kind=ml).
DealMetrics.RecordMlUsage(promptTokens);
await RecordEventAsync(
@@ -142,7 +116,7 @@ public sealed class TokenUsageRecorder
}
/// <summary>
/// Оценка токенов по символам (≈chars/4; конвенция проекта, ai.proto Ruling 5).
/// Оценка токенов по символам.
/// </summary>
/// <param name="text">Текст (null/пустой — 0).</param>
/// <returns>Оценка токенов (неотрицательная).</returns>
@@ -196,7 +170,6 @@ public sealed class TokenUsageRecorder
return id;
}
// Прибавляет usage к накопленному значению aiTokenUsage (lifetime-счётчик, формат этапа 6).
// usage: Оценка токенов ответа.
// ct: Токен отмены.
private async Task AddToLifetimeAsync(Usage usage, CancellationToken ct)
@@ -3,12 +3,12 @@ using Deal.Infrastructure.Integrations.Storage.Options;
namespace Deal.Infrastructure.Integrations.Storage.Extensions;
/// <summary>
/// Расширения <see cref="MinioStorageOptions"/> (выбор MinIO-адаптера по заполненности секции).
/// Расширения <see cref="MinioStorageOptions"/>
/// </summary>
internal static class MinioStorageOptionsExtensions
{
/// <summary>
/// True — секция Minio заполнена настолько, что возможен Minio-адаптер (Ruling 4: Endpoint + креды).
/// True — секция Minio заполнена настолько, что возможен Minio-адаптер.
/// </summary>
/// <param name="minio">Настройки MinIO из секции Storage:Minio.</param>
/// <returns>True — заданы Endpoint, AccessKey и SecretKey.</returns>
@@ -6,7 +6,7 @@ namespace Deal.Infrastructure.Integrations.Storage.Extensions;
internal static class StringExtensions
{
/// <summary>
/// Разбирает строковое значение как булев флаг конфигурации: «true» (без учёта регистра) или «1».
/// Разбирает строковое значение как булев флаг конфигурации
/// </summary>
/// <param name="raw">Сырое значение настройки.</param>
/// <returns>True — значение распознано как включённое.</returns>
@@ -3,18 +3,12 @@ using Deal.Infrastructure.Integrations.Storage.Services;
namespace Deal.Infrastructure.Integrations.Storage.Options;
/// <summary>
/// Локальный режим файлового хранилища — секция <c>Storage:Local</c> (Ruling 4, Task 6).
/// Локальный режим файлового хранилища — секция <c>Storage:Local</c>.
/// </summary>
/// <remarks>
/// Root — каталог вложений: относительный путь резолвится от ContentRoot приложения, абсолютный — как есть
/// (см. <see cref="FileStorageRegistrar.AddDealFileStorage"/>). Пустая секция → дефолт
/// <c>data/attachments</c> под ContentRoot (fallback прототипа object_store.py L5479: FILES_DIR =
/// DATA_DIR/attachments). Режим Local — dev/curl/unit по умолчанию: выбирается, когда MinIO не сконфигурирован.
/// </remarks>
public sealed class LocalStorageOptions
{
/// <summary>
/// Каталог вложений (относительно ContentRoot либо абсолютный); пусто — data/attachments.
/// Каталог вложений
/// </summary>
public string? Root { get; set; }
}
@@ -3,28 +3,17 @@ using Deal.Infrastructure.Integrations.Storage.Services;
namespace Deal.Infrastructure.Integrations.Storage.Options;
/// <summary>
/// MinIO-режим файлового хранилища — секция <c>Storage:Minio</c> (Ruling 4, Task 6).
/// MinIO-режим файлового хранилища — секция <c>Storage:Minio</c>.
/// </summary>
/// <remarks>
/// Источник — секция <c>Storage:Minio</c> (appsettings.json + env <c>Storage__Minio__Endpoint</c>,
/// <c>Storage__Minio__AccessKey</c>, <c>Storage__Minio__SecretKey</c>, <c>Storage__Minio__Bucket</c>,
/// <c>Storage__Minio__Secure</c>; 1:1 с Ruling 4 «креды Storage:Minio … из appsettings/env Storage__Minio__*»).
/// Если секция не задана, регистратор заполняет её из env-алиасов <c>DEAL_MINIO_ENDPOINT</c>/
/// <c>DEAL_MINIO_ACCESS_KEY</c>/<c>DEAL_MINIO_SECRET_KEY</c>/<c>DEAL_MINIO_BUCKET</c>/<c>DEAL_MINIO_SECURE</c>
/// (аналог LEADRADAR_MINIO_* config.py прототипа). Адаптер <see cref="MinioFileStorage"/> регистрируется,
/// только когда Endpoint и AccessKey/SecretKey заполнены (Ruling 4: иначе LocalFileStorage — «заглушка,
/// если MinIO недоступен»). Бакет — единственный (объекты всех карточек в одном бакете, как в прототипе;
/// мульти-аренда объектного хранилища — этап 7 SaaS), по умолчанию deal-files.
/// </remarks>
public sealed class MinioStorageOptions
{
/// <summary>
/// Имя бакета по умолчанию (Ruling 4; python MINIO_BUCKET дефолт из config).
/// Имя бакета по умолчанию.
/// </summary>
public const string DefaultBucketName = "deal-files";
/// <summary>
/// Хост:порт MinIO (например, localhost:9000 или play.min.io).
/// Хост:порт MinIO
/// </summary>
public string? Endpoint { get; set; }
@@ -44,7 +33,7 @@ public sealed class MinioStorageOptions
public string Bucket { get; set; } = DefaultBucketName;
/// <summary>
/// True — HTTPS (WithSSL); dev-compose deal-minio — false (http).
/// True — HTTPS
/// </summary>
public bool Secure { get; set; }
}
@@ -3,25 +3,17 @@ using Deal.Infrastructure.Integrations.Storage.Services;
namespace Deal.Infrastructure.Integrations.Storage.Options;
/// <summary>
/// Настройки файлового хранилища — секция <c>Storage</c> конфигурации (Ruling 4, Task 6).
/// Настройки файлового хранилища — секция <c>Storage</c> конфигурации.
/// </summary>
/// <remarks>
/// Источник — секция <c>Storage</c> (appsettings.json + env <c>Storage__Local__Root</c>,
/// <c>Storage__Minio__Endpoint</c> и т.д.; см. <see cref="MinioStorageOptions"/>) — плюс env-алиасы
/// <c>DEAL_MINIO_*</c> (аналог LEADRADAR_MINIO_* прототипа), которые заполняют секцию Minio, если она не
/// задана (см. <see cref="FileStorageRegistrar.AddDealFileStorage"/>). Читается регистратором вручную
/// (секция маленькая; Binder в Infrastructure не тянем). LocalFileStorage — dev/unit по умолчанию;
/// MinioFileStorage регистрируется, только когда Minio сконфигурирован (Ruling 4).
/// </remarks>
public sealed class StorageOptions
{
/// <summary>
/// Настройки локального режима (root-каталог относительно ContentRoot).
/// Настройки локального режима
/// </summary>
public LocalStorageOptions Local { get; set; } = new();
/// <summary>
/// Настройки MinIO-режима (endpoint/креды/бакет).
/// Настройки MinIO-режима
/// </summary>
public MinioStorageOptions Minio { get; set; } = new();
}
@@ -8,30 +8,17 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Storage.Services;
/// <summary>
/// DI-регистрация файлового хранилища: выбор Local/MinIO по конфигурации (Ruling 4, Task 6).
/// DI-регистрация файлового хранилища
/// </summary>
/// <remarks>
/// <see cref="AddDealFileStorage"/> читает секцию <c>Storage</c> (см. <see cref="StorageOptions"/>) и выбирает
/// адаптер по правилу Ruling 4: секция Minio заполнена (Endpoint и AccessKey/SecretKey) → регистрируется
/// <see cref="MinioFileStorage"/>; иначе — <see cref="LocalFileStorage"/> (root из Storage:Local:Root либо
/// дефолт <c>data/attachments</c> под ContentRoot) — dev/curl/unit идут БЕЗ MinIO (требование «заглушка-
/// адаптер, если MinIO недоступен»). Значения секции Storage:Minio дублируются env-алиасами
/// <c>DEAL_MINIO_ENDPOINT</c>/<c>DEAL_MINIO_ACCESS_KEY</c>/<c>DEAL_MINIO_SECRET_KEY</c>/<c>DEAL_MINIO_BUCKET</c>/
/// <c>DEAL_MINIO_SECURE</c> (аналог LEADRADAR_MINIO_* прототипа config.py): секция (appsettings/env
/// Storage__Minio__*) имеет приоритет, алиасы заполняют незаданные поля. Оба адаптера — singleton:
/// хранилище не привязано к схеме тенанта (объекты — в едином бакете/каталоге; мульти-аренда объектного
/// хранилища — этап 7 SaaS), реализации потокобезопасны. Вызывается из Program.cs
/// (после AddDealIntegrations; contentRoot — IWebHostEnvironment.ContentRootPath).
/// </remarks>
public static class FileStorageRegistrar
{
/// <summary>
/// Имя секции конфигурации файлового хранилища (<c>Storage</c>).
/// Имя секции конфигурации файлового хранилища
/// </summary>
public const string ConfigurationSectionName = "Storage";
/// <summary>
/// Дефолтный каталог вложений локального режима относительно ContentRoot (fallback прототипа: FILES_DIR = DATA_DIR/attachments).
/// Дефолтный каталог вложений локального режима относительно ContentRoot.
/// </summary>
public const string DefaultAttachmentsRelativePath = "data/attachments";
@@ -49,7 +36,7 @@ public static class FileStorageRegistrar
private const string MinioEnvironmentSecureVariableName = "DEAL_MINIO_SECURE";
/// <summary>
/// Регистрирует IFileStorage — LocalFileStorage или MinioFileStorage по конфигурации (Ruling 4).
/// Регистрирует IFileStorage — LocalFileStorage или MinioFileStorage по конфигурации.
/// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <param name="configuration">Конфигурация приложения (секция Storage + env-алиасы DEAL_MINIO_*).</param>
@@ -64,7 +51,6 @@ public static class FileStorageRegistrar
StorageOptions options = ReadOptions(configuration);
// MinIO-режим: только когда секция/алиасы заполнены (Ruling 4: «заглушка-адаптер, если MinIO
// недоступен» — dev/curl/unit по умолчанию работают на LocalFileStorage без MinIO).
if (options.Minio.IsConfigured())
{
@@ -152,7 +138,6 @@ public static class FileStorageRegistrar
return string.IsNullOrWhiteSpace(fromEnvironment) ? null : fromEnvironment;
}
// Env-алиас DEAL_MINIO_* для ключа секции Storage:Minio (аналог LEADRADAR_MINIO_*); Local-ключи алиасов не имеют.
private static string? EnvironmentAliasFor(string sectionKey)
{
return sectionKey switch
@@ -4,21 +4,8 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Storage.Services;
/// <summary>
/// Локальное файловое хранилище вложений — каталог на диске (Ruling 4, Task 6; 1:1 object_store.py L5479).
/// Локальное файловое хранилище вложений — каталог на диске.
/// </summary>
/// <remarks>
/// Dev/curl/unit-режим по умолчанию: используется, когда MinIO не сконфигурирован (Ruling 4 — «заглушка-
/// адаптер, если MinIO недоступен»). Root — абсолютный каталог (по умолчанию <c>data/attachments</c> под
/// ContentRoot, резолвит <see cref="FileStorageRegistrar"/>). Путь из objectKey строится безопасно:
/// ключ делится на сегменты по <c>/</c> (и <c>\</c> — защита не зависит от ОС), сегменты <c>.</c>/<c>..</c>
/// запрещены, итоговый полный путь обязан лежать внутри root (object_store.py L5479 — «не даём выйти за
/// FILES_DIR»). Put — mkdir родителя + запись потока с позиции 0 (Ruling T6: перемотаемый поток сбрасывается
/// в 0 — в отличие от MinIO-адаптера локальный поток не буферизуется: длина тут не нужна); Get — FileStream|null;
/// Stat — FileInfo-дескриптор (размер; contentType пуст — см. ниже); Delete — удаление файла. ContentType не хранится (как
/// прототип: локально пишутся только байты) — дескриптор StatAsync несёт пустой MIME, и download-эндпоинт (Task 9)
/// отвечает фиксированным application/octet-stream (Ruling 4/T6).
/// Потокобезопасен (состояние — только root); регистрируется singleton.
/// </remarks>
public sealed class LocalFileStorage : IFileStorage
{
// Размер буфера чтения при скачивании (async FileStream).
@@ -39,7 +26,7 @@ public sealed class LocalFileStorage : IFileStorage
}
/// <summary>
/// Описание режима для стартового лога Api (приёмка Task 6: LocalFileStorage + путь data/attachments).
/// Описание режима для стартового лога Api.
/// </summary>
/// <returns>Строка вида <c>LocalFileStorage (root: …)</c>.</returns>
public override string ToString() => $"LocalFileStorage (root: {_rootPath})";
@@ -53,7 +40,6 @@ public sealed class LocalFileStorage : IFileStorage
{
ArgumentNullException.ThrowIfNull(content);
// Контракт порта (Ruling T6): Put читает ВСЁ содержимое с позиции 0 — поток-источник (multipart)
// может быть прочитан эндпоинтом раньше; перемотаемые потоки сбрасываем (неперемотаемые читаются
// с текущей позиции, как есть). Выравнивание с Minio-адаптером PutAsync.
if (content.CanSeek && content.Position != 0)
@@ -80,7 +66,6 @@ public sealed class LocalFileStorage : IFileStorage
return Task.FromResult<Stream?>(null);
}
// FileStream отдаётся вызывающему «как есть» (владелец — вызывающий, он же закрывает; python — BytesIO).
FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read, FileBufferSize, FileOptions.Asynchronous);
return Task.FromResult<Stream?>(stream);
}
@@ -94,8 +79,6 @@ public sealed class LocalFileStorage : IFileStorage
return Task.FromResult<FileMeta?>(null);
}
// ContentType локально не хранится (put пишет только байты, как прототип) — дескриптор несёт пустой
// MIME (см. FileMeta); download-эндпоинт (Task 9) отвечает application/octet-stream (Ruling 4/T6).
FileInfo info = new(path);
return Task.FromResult<FileMeta?>(new FileMeta(objectKey, info.Length, string.Empty));
}
@@ -112,7 +95,6 @@ public sealed class LocalFileStorage : IFileStorage
return Task.CompletedTask;
}
// Безопасно резолвит objectKey в путь внутри root (object_store.py _local_path L5479).
// objectKey: Ключ объекта (сегменты по '/', без «.»/«..»).
// Возвращает: Полный путь файла под root.
// Исключение ArgumentException: objectKey пуст либо содержит обходные сегменты «.»/«..».
@@ -10,24 +10,10 @@ using Minio.Exceptions;
namespace Deal.Infrastructure.Integrations.Storage.Services;
/// <summary>
/// Хранилище вложений на MinIO (S3-совместимое) — Minio .NET SDK (Ruling 4, Task 6; 1:1 object_store.py L26107).
/// Хранилище вложений на MinIO
/// </summary>
/// <remarks>
/// Регистрируется, только когда MinIO сконфигурирован (секция Storage:Minio / env DEAL_MINIO_* заполнена —
/// см. <see cref="FileStorageRegistrar"/>); иначе действует LocalFileStorage. Клиент строится в конструкторе
/// (без сети), бакет проверяется/создаётся ЛЕНИВО при первом put (object_store.py L2651: bucket_exists/
/// make_bucket один раз; сбой проверки — warning-лог, put продолжит и упадёт — 1:1 с python L4751). Put —
/// буферизация потока в память: MinIO-пути нужна известная длина (Content-Length), а прототип и так держит
/// байты файла в памяти (put L6773); Get — GetObjectAsync с callback-потоком (буфер MemoryStream);
/// Stat — StatObjectAsync → FileMeta (размер + contentType, сохранённый при put);
/// отсутствие объекта (ObjectNotFoundException) → null (как GetAsync порта). Delete гасит MinioException
/// warning-логом (remove L96–108: метаданные карточки чистит сервис в любом случае). Единственный бакет,
/// tenant-префикса в ключах нет — мульти-аренда объектного хранилища этапом 7 SaaS. Потокобезопасен
/// (клиент SDK thread-safe, проверка бакета под gate); регистрируется singleton.
/// </remarks>
public sealed class MinioFileStorage : IFileStorage
{
// ContentType по умолчанию, когда загрузка не указала MIME (object_store.py L72).
private const string DefaultContentType = "application/octet-stream";
private readonly IMinioClient _client;
@@ -35,7 +21,6 @@ public sealed class MinioFileStorage : IFileStorage
private readonly string _bucket;
private readonly ILogger<MinioFileStorage> _logger;
// Семафор ленивой проверки/создания бакета (гонка первых put, object_store.py L4451).
private readonly SemaphoreSlim _bucketCheckGate = new(1, 1);
private bool _bucketChecked;
@@ -76,7 +61,7 @@ public sealed class MinioFileStorage : IFileStorage
}
/// <summary>
/// Описание режима для стартового лога Api (endpoint/бакет, без секретов).
/// Описание режима для стартового лога Api
/// </summary>
/// <returns>Строка вида <c>MinioFileStorage (endpoint: …; bucket: …)</c>.</returns>
public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})";
@@ -92,7 +77,6 @@ public sealed class MinioFileStorage : IFileStorage
await EnsureBucketAsync(ct);
// Прототип держит байты файла в памяти (put L67–73); MinIO-пути нужна известная длина объекта
// (Content-Length), поэтому поток буферизуется — Local-адаптер буферизации не требует.
if (content.CanSeek && content.Position != 0)
{
@@ -137,8 +121,6 @@ public sealed class MinioFileStorage : IFileStorage
}
catch (Exception)
{
// Любая иная ошибка (сеть/MinIO недоступен и т.п.): частично заполненный буфер не течёт (Ruling T6),
// ошибка уходит вызывающему (эндпоинт Task 9 мапит её в 404 «Файл не найден в MinIO»).
buffer.Dispose();
throw;
}
@@ -152,8 +134,6 @@ public sealed class MinioFileStorage : IFileStorage
{
try
{
// Стат объекта: размер и contentType (кладётся при put, см. PutAsync) — download-эндпоинт (Task 9)
// отвечает Content-Length/Content-Type из дескриптора (Ruling T6; объекта нет → ObjectNotFoundException
// → null-семантика порта). Иные ошибки (MinIO недоступен) уходят вызывающему — он мапит их в 404.
ObjectStat stat = await _client.StatObjectAsync(
new StatObjectArgs().WithBucket(_bucket).WithObject(objectKey),
@@ -178,8 +158,6 @@ public sealed class MinioFileStorage : IFileStorage
}
catch (MinioException exception)
{
// 1:1 object_store.py remove L96108: сбой MinIO (недоступен, бакет не создан) гасим warning-логом —
// метаданные карточки (FilesJson) чистит сервис в любом случае (Task 7).
_logger.LogWarning(
exception,
"Не удалось удалить объект MinIO «{ObjectKey}» из бакета «{Bucket}»: {Message}",
@@ -189,7 +167,6 @@ public sealed class MinioFileStorage : IFileStorage
}
}
// Ленивая проверка/создание бакета при первом put (object_store.py L2651).
// ct: Токен отмены.
private async Task EnsureBucketAsync(CancellationToken ct)
{
@@ -217,7 +194,6 @@ public sealed class MinioFileStorage : IFileStorage
catch (MinioException exception)
{
// Бакет не проверить/создать (MinIO недоступен и т.п.): put продолжит и упадёт с понятной
// ошибкой; 1:1 object_store.py L4751 (python логирует warning и не бросает на проверке).
_logger.LogWarning(
exception,
"Не удалось проверить/создать бакет MinIO «{Bucket}»: {Message}",
@@ -1,12 +1,12 @@
namespace Deal.Infrastructure.Migrations;
/// <summary>
/// Миграции схем тенантов. Чистые функции формирования SQL.
/// Миграции схем тенантов.
/// </summary>
public static class TenantSchemaMigrator
{
/// <summary>
/// SQL создания схемы тенанта. Имя экранируется (не интерполируется из ввода).
/// SQL создания схемы тенанта.
/// </summary>
public static string CreateSchemaSql(string schemaName)
{
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация записи аудита: таблица audit_log в схеме public (append-only).
/// EF-конфигурация записи аудита
/// </summary>
public sealed class AuditLogConfiguration : IEntityTypeConfiguration<AuditLogEntity>
{
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация карточки канбана: таблица Cards (модель без схемы).
/// EF-конфигурация карточки канбана
/// </summary>
public sealed class CardConfiguration : IEntityTypeConfiguration<CardEntity>
{
@@ -37,7 +37,6 @@ public sealed class CardConfiguration : IEntityTypeConfiguration<CardEntity>
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\",''))",
@@ -5,9 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация журнала действий над карточками: таблица CardMoves (модель без схемы).
/// EF-конфигурация журнала действий над карточками
/// </summary>
/// <remarks>Без внешних ключей: журнал живёт дольше карточки (прототип _hard_delete его не чистит).</remarks>
public sealed class CardMoveConfiguration : IEntityTypeConfiguration<CardMoveEntity>
{
public void Configure(EntityTypeBuilder<CardMoveEntity> builder)
@@ -5,13 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация единого контейнера: таблица Containers (модель без схемы).
/// EF-конфигурация единого контейнера
/// </summary>
/// <remarks>
/// Аддитивный слой этапа 9: единый реестр контейнеров (колонки/стадии/зоны) вместо прежних board-строк.
/// переключение хранилища/сервисов — следующими задачами (T3/T4). SearchTsv — полнотекстовый индекс
/// для поиска контейнеров (title/description), как Cards.SearchTsv.
/// </remarks>
public sealed class ContainerConfiguration : IEntityTypeConfiguration<ContainerEntity>
{
public void Configure(EntityTypeBuilder<ContainerEntity> builder)
@@ -5,12 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация дедуп-хэшей пайплайна: таблица DedupEntries (модель без схемы).
/// EF-конфигурация дедуп-хэшей пайплайна
/// </summary>
/// <remarks>
/// Без внешних ключей: LeadId — «мягкая» ссылка на Cards; при жёстком удалении карточки строки чистит
/// приложение (Ruling 3), чтобы «сирота» не блокировала повторное создание карточки.
/// </remarks>
public sealed class DedupEntryConfiguration : IEntityTypeConfiguration<DedupEntryEntity>
{
public void Configure(EntityTypeBuilder<DedupEntryEntity> builder)
@@ -5,9 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация каталога диалогов: таблица Dialogs (модель без схемы; Ruling 7).
/// EF-конфигурация каталога диалогов
/// </summary>
/// <remarks>Индексов нет — каталог читается целиком (список вкладки ≤500 диалогов) и по PK.</remarks>
public sealed class DialogConfiguration : IEntityTypeConfiguration<DialogEntity>
{
public void Configure(EntityTypeBuilder<DialogEntity> builder)
@@ -16,7 +15,6 @@ public sealed class DialogConfiguration : IEntityTypeConfiguration<DialogEntity>
builder.HasKey(x => x.Id);
// Дефолт цвета каталога (1:1 db.py L81 — hue VARCHAR NOT NULL DEFAULT '#666').
builder.Property(x => x.Hue).HasDefaultValue("#666");
}
}
@@ -5,13 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация чёрного списка Discovery: таблица DiscBlacklist (модель без схемы; Ruling 9).
/// EF-конфигурация чёрного списка Discovery
/// </summary>
/// <remarks>
/// 1:1 db.py L180185. Список читается целиком (вкладка) и по PK (проверки add_candidate/воркера) — индексов
/// не требуется. Повторная вставка того же диалога — ON CONFLICT DO UPDATE name/reason (python L572575):
/// адаптер реализует upsert кодом (CreatedAt сохраняется).
/// </remarks>
public sealed class DiscBlacklistConfiguration : IEntityTypeConfiguration<DiscBlacklistEntity>
{
public void Configure(EntityTypeBuilder<DiscBlacklistEntity> builder)
@@ -5,13 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация кандидата Discovery: таблица DiscCandidates (модель без схемы; Ruling 9).
/// EF-конфигурация кандидата Discovery
/// </summary>
/// <remarks>
/// 1:1 db.py L159177. Marks/Topics — JSON-массивы в text. Составной индекс (TaskId, Status) — python
/// idx_disc_cand_task L177: выборка кандидатов задачи по статусу (списки воркера/вкладки). Без FK на DiscTasks:
/// удаление задачи чистит кандидатов каскадом в коде сервиса (delete_task L314318).
/// </remarks>
public sealed class DiscCandidateConfiguration : IEntityTypeConfiguration<DiscCandidateEntity>
{
public void Configure(EntityTypeBuilder<DiscCandidateEntity> builder)
@@ -5,12 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация лога Discovery: таблица DiscLog (модель без схемы; Ruling 9).
/// EF-конфигурация лога Discovery
/// </summary>
/// <remarks>
/// 1:1 db.py L189196. Составной индекс (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)
@@ -5,12 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация задачи поиска Discovery: таблица DiscTasks (модель без схемы; Ruling 9).
/// EF-конфигурация задачи поиска Discovery
/// </summary>
/// <remarks>
/// 1:1 db.py L136156. Keywords — JSON-массив в text (конвенция JSON-колонок этапов 1–5). Индексов нет —
/// python idx для disc_tasks не создаёт: задачи читаются списком целиком (воркер/список вкладки) и по PK.
/// </remarks>
public sealed class DiscTaskConfiguration : IEntityTypeConfiguration<DiscTaskEntity>
{
public void Configure(EntityTypeBuilder<DiscTaskEntity> builder)
@@ -5,8 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация глобальной (системной) настройки оператора: таблица global_settings
/// в схеме public (ТЗ §4.1/§8.1).
/// EF-конфигурация глобальной
/// </summary>
public sealed class GlobalSettingConfiguration : IEntityTypeConfiguration<GlobalSettingEntity>
{
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация приглашения: таблица invites в схеме public.
/// EF-конфигурация приглашения
/// </summary>
public sealed class InviteConfiguration : IEntityTypeConfiguration<InviteEntity>
{
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация комментария карточки: таблица LeadComments (модель без схемы).
/// EF-конфигурация комментария карточки
/// </summary>
public sealed class LeadCommentConfiguration : IEntityTypeConfiguration<LeadCommentEntity>
{
@@ -15,7 +15,6 @@ public sealed class LeadCommentConfiguration : IEntityTypeConfiguration<LeadComm
builder.HasKey(x => x.Id);
// Комментарии карточки читаются вместе с ней; удаление карточки каскадно чистит комментарии (Ruling 10).
builder.HasOne<CardEntity>()
.WithMany()
.HasForeignKey(x => x.CardId)
@@ -5,9 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация очереди обучающих сигналов ML: таблица MlOutbox (модель без схемы).
/// EF-конфигурация очереди обучающих сигналов ML
/// </summary>
/// <remarks>Без внешних ключей: очередь не зависит от карточек и чистится отдельно (ResetAsync).</remarks>
public sealed class MlOutboxConfiguration : IEntityTypeConfiguration<MlOutboxEntity>
{
public void Configure(EntityTypeBuilder<MlOutboxEntity> builder)
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация оператора: таблица operators в схеме public.
/// EF-конфигурация оператора
/// </summary>
public sealed class OperatorConfiguration : IEntityTypeConfiguration<OperatorEntity>
{
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация сессии оператора: таблица operator_sessions в схеме public.
/// EF-конфигурация сессии оператора
/// </summary>
public sealed class OperatorSessionConfiguration : IEntityTypeConfiguration<OperatorSessionEntity>
{
@@ -5,9 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация очереди входящих пайплайна: таблица QueueItems (модель без схемы).
/// EF-конфигурация очереди входящих пайплайна
/// </summary>
/// <remarks>Без внешних ключей: очередь не зависит от карточек и чистится воркером/очистками.</remarks>
public sealed class QueueItemConfiguration : IEntityTypeConfiguration<QueueItemEntity>
{
public void Configure(EntityTypeBuilder<QueueItemEntity> builder)
@@ -16,7 +15,6 @@ public sealed class QueueItemConfiguration : IEntityTypeConfiguration<QueueItemE
builder.HasKey(x => x.Id);
// Текст сообщения — text; лимит 6000 символов применяет сервис при приёме (Ruling 2), не БД.
builder.Property(x => x.Text).HasColumnType("text");
// Выборка pump'а идёт по статусу и времени постановки (status='new', лимит 12).
@@ -5,8 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация счётчика фиксированного окна: таблица rate_limit_counters в схеме public
/// (этап 12, пакет B — распределённый rate-limit и учёт попыток входа).
/// EF-конфигурация счётчика фиксированного окна
/// </summary>
public sealed class RateLimitCounterConfiguration : IEntityTypeConfiguration<RateLimitCounterEntity>
{
@@ -5,12 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация отсева пайплайна: таблица RejectedItems (модель без схемы).
/// EF-конфигурация отсева пайплайна
/// </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)
@@ -23,14 +19,12 @@ public sealed class RejectedItemConfiguration : IEntityTypeConfiguration<Rejecte
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");
}
}
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация сессии: таблица sessions в схеме public.
/// EF-конфигурация сессии
/// </summary>
public sealed class SessionConfiguration : IEntityTypeConfiguration<SessionEntity>
{
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация тенанта: таблица tenants в схеме public.
/// EF-конфигурация тенанта
/// </summary>
public sealed class TenantConfiguration : IEntityTypeConfiguration<TenantEntity>
{
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация лимита тенанта: таблица tenant_limits в схеме public.
/// EF-конфигурация лимита тенанта
/// </summary>
public sealed class TenantLimitConfiguration : IEntityTypeConfiguration<TenantLimitEntity>
{
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация настройки тенанта: таблица settings в схеме тенанта (модель без схемы).
/// EF-конфигурация настройки тенанта
/// </summary>
public sealed class TenantSettingConfiguration : IEntityTypeConfiguration<TenantSettingEntity>
{
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация превью-сообщений: таблица TgMessages (модель без схемы; Ruling 7, db.py L6774).
/// EF-конфигурация превью-сообщений
/// </summary>
public sealed class TgMessageConfiguration : IEntityTypeConfiguration<TgMessageEntity>
{
@@ -15,7 +15,6 @@ public sealed class TgMessageConfiguration : IEntityTypeConfiguration<TgMessageE
builder.HasKey(x => x.Id);
// Фолбэк превью диалога читается по диалогу в порядке времени (idx_messages_dialog db.py L74).
builder.HasIndex(x => new { x.DialogId, x.MsgAt });
}
}
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация события расхода токенов: таблица token_usage_events в схеме public (append-only).
/// EF-конфигурация события расхода токенов
/// </summary>
public sealed class TokenUsageEventConfiguration : IEntityTypeConfiguration<TokenUsageEventEntity>
{
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Deal.Infrastructure.Persistence.Configurations;
/// <summary>
/// EF-конфигурация пользователя: таблица users в схеме public.
/// EF-конфигурация пользователя
/// </summary>
public sealed class UserConfiguration : IEntityTypeConfiguration<UserEntity>
{
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence;
/// <summary>
/// Системный DbContext: схема public (тенанты, пользователи, сессии, операторы, инвайты, лимиты, аудит).
/// Системный DbContext
/// </summary>
public sealed class DealDbContext(DbContextOptions<DealDbContext> options) : DbContext(options)
{
@@ -26,18 +26,17 @@ public sealed class DealDbContext(DbContextOptions<DealDbContext> options) : DbC
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>();
@@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore.Design;
namespace Deal.Infrastructure.Persistence;
/// <summary>
/// Фабрика для dotnet-ef (миграции). Читает строку подключения из env.
/// Фабрика для dotnet-ef
/// </summary>
public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory<DealDbContext>
{
@@ -1,7 +1,7 @@
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
/// Запись аудита (append-only) в системной схеме public.
/// Запись аудита
/// </summary>
public sealed class AuditLogEntity
{
@@ -26,7 +26,7 @@ public sealed class AuditLogEntity
public string? Ip { get; set; }
/// <summary>
/// Детали события в JSON (без секретов).
/// Детали события в JSON
/// </summary>
public string? DetailJson { get; set; }
}
@@ -3,33 +3,32 @@ using NpgsqlTypes;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
/// Карточка канбана: таблица Cards в схеме тенанта. Соответствует таблице leads прототипа.
/// Карточка канбана
/// </summary>
public sealed class CardEntity
{
/// <summary>
/// Короткий id карточки (префикс <c>c_</c>), первичный ключ.
/// Короткий id карточки
/// </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; }
@@ -39,32 +38,32 @@ public sealed class CardEntity
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).
/// Стек/направления, сериализованные в JSON
/// </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;
@@ -79,17 +78,17 @@ public sealed class CardEntity
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).
/// Квалифицированные контакты, сериализованные в JSON
/// </summary>
public string ContactsJson { get; set; } = "[]";
@@ -104,22 +103,22 @@ public sealed class CardEntity
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 диалога исходного сообщения (для «открыть исходник»).
/// Id диалога исходного сообщения
/// </summary>
public string SourceDialogId { get; set; } = string.Empty;
@@ -129,52 +128,52 @@ public sealed class CardEntity
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).
/// Совпавшие критерии правил при попадании в колонку, сериализованные в JSON
/// </summary>
public string MatchHitsJson { get; set; } = "[]";
/// <summary>
/// Ссылки карточки, сериализованные в JSON (text; элементы {id,name,url}).
/// Ссылки карточки, сериализованные в JSON
/// </summary>
public string LinksJson { get; set; } = "[]";
/// <summary>
/// Файлы карточки, сериализованные в JSON (text; элементы {id,name,size,kind,label,objectKey}).
/// Файлы карточки, сериализованные в JSON
/// </summary>
public string FilesJson { get; set; } = "[]";
/// <summary>
/// История движения карточки, сериализованная в JSON (text; элементы {id,at,type|stage}).
/// История движения карточки, сериализованная в JSON
/// </summary>
public string HistoryJson { get; set; } = "[]";
/// <summary>
/// Текст технического задания по карточке (заметка-задание).
/// Текст технического задания по карточке
/// </summary>
public string TzText { get; set; } = string.Empty;
/// <summary>
/// Время напоминания об отложенной карточке, либо null (напоминание не задано/сброшено).
/// Время напоминания об отложенной карточке, либо 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;
@@ -184,7 +183,7 @@ public sealed class CardEntity
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// Время последнего изменения карточки (сортировка пространства «Выбранные» — UpdatedAt DESC).
/// Время последнего изменения карточки
/// </summary>
public DateTimeOffset UpdatedAt { get; set; }
}
@@ -1,36 +1,32 @@
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>), первичный ключ.
/// Короткий id записи журнала
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// Id карточки (Cards.Id), над которой выполнено действие. Без FK — журнал хранится и после удаления карточки.
/// Id карточки (Cards.Id), над которой выполнено действие.
/// </summary>
public string LeadId { get; set; } = string.Empty;
/// <summary>
/// Действие: <c>move|trash|restore|comment</c> (счётчик learning = число записей).
/// Действие: <c>move|trash|restore|comment</c>
/// </summary>
public string Action { get; set; } = string.Empty;
/// <summary>
/// Колонка-источник переноса, либо null (комментарий).
/// Колонка-источник переноса, либо null
/// </summary>
public string? FromCol { get; set; }
/// <summary>
/// Колонка-назначение переноса, либо null (комментарий).
/// Колонка-назначение переноса, либо null
/// </summary>
public string? ToCol { get; set; }
@@ -3,30 +3,22 @@ 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).
/// Короткий id контейнера
/// </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;
@@ -36,47 +28,47 @@ public sealed class ContainerEntity
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).
/// Вид контейнера: board
/// </summary>
public string Kind { get; set; } = "board";
/// <summary>
/// Пространство: dashboard | selected (вид дашборда, к которому принадлежит контейнер).
/// Пространство: 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;
@@ -1,21 +1,17 @@
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).
/// Id созданной карточки
/// </summary>
public string? LeadId { get; set; }
@@ -1,22 +1,17 @@
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
/// Диалог/канал каталога тенанта: таблица Dialogs в схеме тенанта (Ruling 7, db.py L7686).
/// Диалог/канал каталога тенанта
/// </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…», группы «-…», личные «+…»), первичный ключ.
/// Подписанный id диалога
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// Отображаемое имя диалога (title/first_name).
/// Отображаемое имя диалога
/// </summary>
public string Name { get; set; } = string.Empty;
@@ -26,37 +21,37 @@ public sealed class DialogEntity
public string Handle { get; set; } = string.Empty;
/// <summary>
/// Тип источника: channel|group|forum|chat (EN-канон telegram.proto).
/// Тип источника: channel|group|forum|chat
/// </summary>
public string Kind { get; set; } = string.Empty;
/// <summary>
/// Цвет источника из палитры DIALOG_HUES (hex «#rrggbb»); дефолт «#666» (db.py L81).
/// Цвет источника из палитры DIALOG_HUES
/// </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; }
}
@@ -1,14 +1,8 @@
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
/// Чёрный список Discovery: таблица DiscBlacklist в схеме тенанта (db.py L180185, Ruling 9).
/// Чёрный список Discovery
/// </summary>
/// <remarks>
/// Владелец — модуль Deal.Modules.Discovery (Task 17). Список общий для всех задач: источники из него
/// пропускаются поиском (add_candidate) и повторной проверкой перед авто-вступлением (воркер). Снимается
/// вручную или при ручном join. Повторное добавление обновляет Name/Reason и сохраняет CreatedAt
/// (ON CONFLICT DO UPDATE — python L572575).
/// </remarks>
public sealed class DiscBlacklistEntity
{
/// <summary>
@@ -17,17 +11,17 @@ public sealed class DiscBlacklistEntity
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; }
}
@@ -1,18 +1,12 @@
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
/// Кандидат задачи Discovery: таблица DiscCandidates в схеме тенанта (db.py L159176, Ruling 9).
/// Кандидат задачи Discovery
/// </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…», группы «-…»), первичный ключ.
/// Подписанный id источника
/// </summary>
public string DialogId { get; set; } = string.Empty;
@@ -22,7 +16,7 @@ public sealed class DiscCandidateEntity
public string TaskId { get; set; } = string.Empty;
/// <summary>
/// Отображаемое имя источника (пусто → DialogId).
/// Отображаемое имя источника
/// </summary>
public string Name { get; set; } = string.Empty;
@@ -37,12 +31,12 @@ public sealed class DiscCandidateEntity
public string Kind { get; set; } = "channel";
/// <summary>
/// Цвет источника из палитры DIALOG_HUES (hex «#rrggbb»); дефолт «#666».
/// Цвет источника из палитры DIALOG_HUES
/// </summary>
public string Hue { get; set; } = "#666";
/// <summary>
/// Число участников источника; null — неизвестно (до discovery_info).
/// Число участников источника; null — неизвестно
/// </summary>
public int? Participants { get; set; }
@@ -52,42 +46,42 @@ public sealed class DiscCandidateEntity
public bool? LangRu { get; set; }
/// <summary>
/// Метки оценки, сериализованные в JSON (text; дефолт «[]»).
/// Метки оценки, сериализованные в JSON
/// </summary>
public string MarksJson { get; set; } = "[]";
/// <summary>
/// Оценка тем форума, сериализованная в JSON (text; дефолт «[]»).
/// Оценка тем форума, сериализованная в JSON
/// </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; }
}
@@ -1,17 +1,12 @@
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
/// Лог событий задачи Discovery: таблица DiscLog в схеме тенанта (db.py L189195, Ruling 9).
/// Лог событий задачи Discovery
/// </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>), первичный ключ.
/// Короткий id записи
/// </summary>
public string Id { get; set; } = string.Empty;
@@ -26,12 +21,12 @@ public sealed class DiscLogEntity
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; }
}
@@ -1,56 +1,52 @@
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
/// Задача поиска Discovery: таблица DiscTasks в схеме тенанта (db.py L136156, Ruling 9).
/// Задача поиска Discovery
/// </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>), первичный ключ.
/// Короткий id задачи
/// </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; дефолт «[]»).
/// Ключевые слова поиска, сериализованные в JSON
/// </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;
@@ -65,7 +61,7 @@ public sealed class DiscTaskEntity
public string Status { get; set; } = "draft";
/// <summary>
/// Индекс текущего ключа поиска (прогресс прохода по keywords).
/// Индекс текущего ключа поиска
/// </summary>
public int SearchIdx { get; set; }
@@ -95,12 +91,12 @@ public sealed class DiscTaskEntity
public int Rejected { get; set; }
/// <summary>
/// Момент создания задачи (UTC).
/// Момент создания задачи
/// </summary>
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// Момент последнего изменения (UTC).
/// Момент последнего изменения
/// </summary>
public DateTimeOffset UpdatedAt { get; set; }
}
@@ -1,17 +1,12 @@
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.Models.GlobalSettingsKeys"/>).
/// </remarks>
public sealed class GlobalSettingEntity
{
/// <summary>
/// Ключ глобальной настройки (PK).
/// Ключ глобальной настройки
/// </summary>
public string Key { get; set; } = string.Empty;
@@ -21,7 +16,7 @@ public sealed class GlobalSettingEntity
public string Value { get; set; } = string.Empty;
/// <summary>
/// Время последнего изменения (UTC).
/// Время последнего изменения
/// </summary>
public DateTimeOffset UpdatedAt { get; set; }
}
@@ -1,17 +1,17 @@
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 приглашённого, нормализованный (нижний регистр); уникален среди активных.
/// Email приглашённого, нормализованный
/// </summary>
public string Email { get; set; } = string.Empty;
@@ -25,7 +25,7 @@ public sealed class InviteEntity
public DateTimeOffset ExpiresAt { get; set; }
/// <summary>
/// Момент активации (null, пока инвайт не использован).
/// Момент активации
/// </summary>
public DateTimeOffset? ActivatedAt { get; set; }
@@ -1,12 +1,12 @@
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
/// Комментарий карточки: таблица LeadComments в схеме тенанта. Нормализация массива comments строки leads прототипа.
/// Комментарий карточки
/// </summary>
public sealed class LeadCommentEntity
{
/// <summary>
/// Короткий id комментария (префикс <c>cm_</c>), первичный ключ.
/// Короткий id комментария
/// </summary>
public string Id { get; set; } = string.Empty;
@@ -16,7 +16,7 @@ public sealed class LeadCommentEntity
public string CardId { get; set; } = string.Empty;
/// <summary>
/// Автор комментария (в прототипе — «Вы»), отдаётся как <c>by</c>.
/// Автор комментария, отдаётся как <c>by</c>.
/// </summary>
public string By { get; set; } = string.Empty;
@@ -26,7 +26,7 @@ public sealed class LeadCommentEntity
public string Text { get; set; } = string.Empty;
/// <summary>
/// Время добавления комментария (человеческую метку <c>time</c> считает маппинг).
/// Время добавления комментария
/// </summary>
public DateTimeOffset CreatedAt { get; set; }
}
@@ -1,31 +1,27 @@
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
/// Строка очереди обучающих сигналов ML: таблица MlOutbox в схеме тенанта. Соответствует таблице ml_outbox прототипа.
/// Строка очереди обучающих сигналов ML
/// </summary>
/// <remarks>
/// Действия пользователя всегда пишутся сюда синхронно; фоновый воркер отправляет строки в ML-сервис
/// (этап 3 — только накопление; отправка — этап 6). Без FK — очередь не зависит от карточек.
/// </remarks>
public sealed class MlOutboxEntity
{
/// <summary>
/// Короткий id записи outbox (префикс <c>mle_</c>), первичный ключ.
/// Короткий id записи outbox
/// </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>.
/// Метка обучения: id доски
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// Весовой коэффициент сигнала (1.0 — учить, −1.0 — снять метку).
/// Весовой коэффициент сигнала
/// </summary>
public double Delta { get; set; } = 1.0;
@@ -8,7 +8,7 @@ public sealed class OperatorEntity
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Логин оператора: уникальный, хранится в нижнем регистре.
/// Логин оператора
/// </summary>
public string Login { get; set; } = string.Empty;
@@ -1,12 +1,12 @@
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
/// Сессия оператора в системной схеме public. Хранится SHA-256-хеш токена.
/// Сессия оператора в системной схеме public.
/// </summary>
public sealed class OperatorSessionEntity
{
/// <summary>
/// SHA-256-хеш токена сессии оператора (первичный ключ).
/// SHA-256-хеш токена сессии оператора
/// </summary>
public string TokenHash { get; set; } = string.Empty;
@@ -1,22 +1,17 @@
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>), первичный ключ.
/// Короткий id строки очереди
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// Id диалога-источника (для «открыть исходник» и дубль-гварда по msgId при приёме).
/// Id диалога-источника
/// </summary>
public string DialogId { get; set; } = string.Empty;
@@ -31,22 +26,22 @@ public sealed class QueueItemEntity
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).
/// Id исходного сообщения в Telegram, либо null
/// </summary>
public long? MsgId { get; set; }
/// <summary>
/// Время получения исходного сообщения (stale-проверка правила автоархива).
/// Время получения исходного сообщения
/// </summary>
public DateTimeOffset MsgAt { get; set; }
@@ -56,7 +51,7 @@ public sealed class QueueItemEntity
public string Status { get; set; } = "new";
/// <summary>
/// Признак возврата из отсева: фильтры-отсев для строки игнорируются.
/// Признак возврата из отсева
/// </summary>
public bool Force { get; set; }
@@ -1,27 +1,22 @@
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
/// Счётчик фиксированного окна в системной схеме public (этап 12, пакет B).
/// Счётчик фиксированного окна в системной схеме public.
/// </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; }
@@ -3,24 +3,17 @@ 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>), первичный ключ.
/// Короткий id записи
/// </summary>
/// <remarks>Детерминированный <c>r_&lt;dialog&gt;_&lt;msgId&gt;</c> при наличии dialog+msgId, иначе <c>r_</c>+hex (upsert по id).</remarks>
public string Id { get; set; } = string.Empty;
/// <summary>
/// Id диалога-источника (для повторного возврата в очередь); пусто — диалог неизвестен.
/// Id диалога-источника
/// </summary>
public string DialogId { get; set; } = string.Empty;
@@ -40,12 +33,12 @@ public sealed class RejectedItemEntity
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;
@@ -55,27 +48,27 @@ public sealed class RejectedItemEntity
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; }
@@ -90,12 +83,12 @@ public sealed class RejectedItemEntity
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;
}
@@ -1,12 +1,12 @@
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
/// Сессия пользователя в системной схеме public. Хранится SHA-256-хеш токена.
/// Сессия пользователя в системной схеме public.
/// </summary>
public sealed class SessionEntity
{
/// <summary>
/// SHA-256-хеш токена сессии (первичный ключ).
/// SHA-256-хеш токена сессии
/// </summary>
public string TokenHash { get; set; } = string.Empty;
@@ -22,8 +22,7 @@ public sealed class SessionEntity
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// Маркер impersonation (план Task 7): оператор, создавший сессию; null — обычная сессия.
/// Нужен для аудита impersonation_stopped при logout (AuthService/LogoutAsync).
/// Маркер impersonation
/// </summary>
public Guid? ImpersonatedByOperatorId { get; set; }
}
@@ -6,7 +6,7 @@ namespace Deal.Infrastructure.Persistence.Entities;
public sealed class TenantLimitEntity
{
/// <summary>
/// Тенант, которому принадлежит лимит (первичный ключ).
/// Тенант, которому принадлежит лимит
/// </summary>
public Guid TenantId { get; set; }
@@ -21,7 +21,7 @@ public sealed class TenantLimitEntity
public string Period { get; set; } = "month";
/// <summary>
/// Начало текущего периода (отсчёт от него — срок и ленивый reset).
/// Начало текущего периода
/// </summary>
public DateTimeOffset PeriodStart { get; set; }
@@ -31,12 +31,12 @@ public sealed class TenantLimitEntity
public long UsedTokens { get; set; }
/// <summary>
/// Флаг: тост о расходе 80% бюджета уже отправлен (один на период).
/// Флаг: тост о расходе 80% бюджета уже отправлен
/// </summary>
public bool Warned80 { get; set; }
/// <summary>
/// Флаг: тост об исчерпании бюджета уже отправлен (один на период).
/// Флаг: тост об исчерпании бюджета уже отправлен
/// </summary>
public bool NotifiedExhausted { get; set; }
@@ -1,7 +1,7 @@
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
/// Настройка тенанта: таблица settings в схеме тенанта.
/// Настройка тенанта
/// </summary>
public sealed class TenantSettingEntity
{
@@ -1,17 +1,12 @@
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
/// Строка превью сообщения диалога: таблица TgMessages в схеме тенанта (Ruling 7, db.py L6774).
/// Строка превью сообщения диалога
/// </summary>
/// <remarks>
/// Превью сообщений принятых PushMessage/разборов — фолбэк вкладки «Каналы» (preview) и «последнее сообщение»
/// каталога. Id — «m_&lt;dialog&gt;_&lt;msg&gt;» (python L604). LeadId — мягкая ссылка на карточку по сообщению
/// (без FK — карточки живут в другой таблице и могут удаляться).
/// </remarks>
public sealed class TgMessageEntity
{
/// <summary>
/// Id строки превью («m_&lt;dialog&gt;_&lt;msg&gt;»), первичный ключ.
/// Id строки превью
/// </summary>
public string Id { get; set; } = string.Empty;
@@ -21,17 +16,17 @@ public sealed class TgMessageEntity
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.
/// Id карточки, созданной по сообщению
/// </summary>
public string? LeadId { get; set; }
}
@@ -1,21 +1,21 @@
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;
@@ -25,7 +25,7 @@ public sealed class TokenUsageEventEntity
public string Model { get; set; } = string.Empty;
/// <summary>
/// Вид вызова: ai|ml (TokenUsageEventKinds).
/// Вид вызова: ai|ml
/// </summary>
public string Kind { get; set; } = string.Empty;
@@ -36,7 +36,7 @@ public sealed class TokenUsageEventEntity
public long TotalTokens { get; set; }
/// <summary>
/// Детали события в JSON (без секретов).
/// Детали события в JSON
/// </summary>
public string? DetailJson { get; set; }
}
@@ -8,7 +8,7 @@ public sealed class UserEntity
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Логин пользователя: уникальный, хранится в нижнем регистре.
/// Логин пользователя
/// </summary>
public string Login { get; set; } = string.Empty;
@@ -7,14 +7,8 @@ using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер хранилища аудита: таблица public.audit_log (append-only, Ruling 4).
/// EF-адаптер хранилища аудита
/// </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 />
@@ -113,7 +107,6 @@ public sealed class AuditLogStore(DealDbContext dbContext) : IAuditLogStore
return query;
}
// Клампит размер выборки в 1..MaxQueryLimit (Ruling 4: limit ≤500).
// limit: Запрошенный размер.
// Возвращает: Клампированное значение.
private static int ClampLimit(int limit) => Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit));
@@ -6,9 +6,8 @@ using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер хранилища аутентификации: таблицы public.users и public.sessions.
/// EF-адаптер хранилища аутентификации
/// </summary>
/// <remarks>Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1).</remarks>
public sealed class AuthStore(DealDbContext dbContext) : IAuthStore
{
/// <inheritdoc />
@@ -58,7 +57,6 @@ public sealed class AuthStore(DealDbContext dbContext) : IAuthStore
/// <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)
@@ -5,8 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// Чёрный список Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего
/// файла, поведение не менялось): upsert/удаление/чтение/список DiscBlacklist (ON CONFLICT DO UPDATE).
/// Чёрный список Discovery — partial-часть <see cref="DiscoveryStore"/>
/// </summary>
public sealed partial class DiscoveryStore
{
@@ -31,7 +30,6 @@ public sealed partial class DiscoveryStore
}
else
{
// add_blacklist L572575 (ON CONFLICT DO UPDATE): name/reason обновляются, CreatedAt сохраняется.
row.Name = name;
row.Reason = reason;
}
@@ -5,9 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// Кандидаты Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
/// поведение не менялось): список/чтение кандидатов (DiscCandidates), мониторинг диалога и чёрный список,
/// создание/патч/статусы joined/rejected и счётчик сбоев вступлений.
/// Кандидаты Discovery — partial-часть <see cref="DiscoveryStore"/>
/// </summary>
public sealed partial class DiscoveryStore
{
@@ -124,7 +122,6 @@ public sealed partial class DiscoveryStore
return false;
}
// mark_joined L531534: status=joined + auto_joined.
row.Status = "joined";
row.AutoJoined = autoJoined;
row.UpdatedAt = DateTimeOffset.UtcNow;
@@ -139,7 +136,6 @@ public sealed partial class DiscoveryStore
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null || row.Status != "review")
{
// воркер L404–416: счётчик и удаление трогаем только у живой записи в статусе review.
return null;
}

Some files were not shown because too many files have changed in this diff Show More