diff --git a/src/core/Deal.Api/AdminTickOrchestrator.cs b/src/core/Deal.Api/AdminTickOrchestrator.cs
index e3fbb46..535a3ae 100644
--- a/src/core/Deal.Api/AdminTickOrchestrator.cs
+++ b/src/core/Deal.Api/AdminTickOrchestrator.cs
@@ -1,8 +1,13 @@
using Deal.Api.Events;
-using Deal.Modules.Kanban.Application;
+using Deal.Modules.Kanban.Application.Abstractions;
+using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
-using Deal.Modules.Pipeline.Application;
+using Deal.Modules.Kanban.Application.Registrars;
+using Deal.Modules.Kanban.Application.Services;
+using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
+using Deal.Modules.Pipeline.Application.Registrars;
+using Deal.Modules.Pipeline.Application.Services;
namespace Deal.Api;
diff --git a/src/core/Deal.Api/Configuration/CookieOptions.cs b/src/core/Deal.Api/Configuration/CookieOptions.cs
index 13a56b7..9a6d188 100644
--- a/src/core/Deal.Api/Configuration/CookieOptions.cs
+++ b/src/core/Deal.Api/Configuration/CookieOptions.cs
@@ -1,4 +1,8 @@
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Configuration;
diff --git a/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs b/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs
index 37c489c..ccc61f7 100644
--- a/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs
+++ b/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs
@@ -1,4 +1,8 @@
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Configuration;
diff --git a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs b/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs
index 27e8bb8..6a1e105 100644
--- a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs
+++ b/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs
@@ -1,7 +1,9 @@
using System.Text.Json;
using Deal.Api.Http;
-using Deal.Modules.Settings.Application;
+using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs
index 2ecebc8..c60c091 100644
--- a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs
@@ -1,7 +1,10 @@
using Deal.Api.Http;
using Deal.Api.Middleware;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Microsoft.Extensions.Options;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
using CookieOptions = Deal.Api.Configuration.CookieOptions;
diff --git a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs
index 3b61fa7..6302a73 100644
--- a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs
@@ -3,9 +3,16 @@ using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
-using Deal.Modules.Kanban.Application;
+using Deal.Modules.Kanban.Application.Abstractions;
+using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Kanban.Application.Registrars;
+using Deal.Modules.Kanban.Application.Services;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs
index a945a76..ab599b4 100644
--- a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs
@@ -4,11 +4,20 @@ using Deal.Api.Http;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
-using Deal.Modules.Kanban.Application;
+using Deal.Modules.Kanban.Application.Abstractions;
+using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
-using Deal.Modules.Pipeline.Application;
+using Deal.Modules.Kanban.Application.Registrars;
+using Deal.Modules.Kanban.Application.Services;
+using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Pipeline.Application.Registrars;
+using Deal.Modules.Pipeline.Application.Services;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs
index 1655bb8..27b5331 100644
--- a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs
@@ -1,9 +1,16 @@
using System.Text.Json;
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
-using Deal.Modules.Kanban.Application;
+using Deal.Modules.Kanban.Application.Abstractions;
+using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Kanban.Application.Registrars;
+using Deal.Modules.Kanban.Application.Services;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs
index f1b2dc4..59455c9 100644
--- a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs
@@ -3,10 +3,16 @@ using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
-using Deal.Modules.Discovery.Application;
+using Deal.Modules.Discovery.Application.Abstractions;
+using Deal.Modules.Discovery.Application.Exceptions;
+using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
-using Deal.Modules.Settings.Application;
+using Deal.Modules.Discovery.Application.Registrars;
+using Deal.Modules.Discovery.Application.Services;
+using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Telegram.Application;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs b/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs
index 61a44e6..c1cfe2c 100644
--- a/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs
@@ -1,6 +1,8 @@
using Deal.Api.Http;
-using Deal.Modules.Settings.Application;
+using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Endpoints/JoinEndpoint.cs b/src/core/Deal.Api/Endpoints/JoinEndpoint.cs
index 2cfb729..09c54fa 100644
--- a/src/core/Deal.Api/Endpoints/JoinEndpoint.cs
+++ b/src/core/Deal.Api/Endpoints/JoinEndpoint.cs
@@ -1,6 +1,9 @@
using Deal.Api.Http;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Endpoints/MlEndpoints.cs b/src/core/Deal.Api/Endpoints/MlEndpoints.cs
index b3642e0..0862890 100644
--- a/src/core/Deal.Api/Endpoints/MlEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/MlEndpoints.cs
@@ -1,8 +1,10 @@
using Deal.Api.Http;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
-using Deal.Modules.Pipeline.Application;
+using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
+using Deal.Modules.Pipeline.Application.Registrars;
+using Deal.Modules.Pipeline.Application.Services;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs
index 94b77d3..d71a6e1 100644
--- a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs
@@ -1,168 +1,171 @@
-using Deal.Api.Http;
-using Deal.Modules.Tenants.Application;
-using Deal.Modules.Tenants.Application.Models;
-
-namespace Deal.Api.Endpoints;
-
-///
-/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3).
-///
-///
-/// Только под операторской сессией: без неё 401 «Требуется вход оператора» (как прочие /api/operator/*).
-/// Ничего не меняет (read-only). groupBy — day|tenant|provider|model (неизвестное — 400 {detail}); from/to —
-/// ISO-8601 (включительно), как у аудита; activity поддерживает фильтры eventType/actorType/actorId/tenantId,
-/// limit (1..500) и offset. Все ответы — camelCase (контракт: docs/architecture/2026-09-10-operator-analytics-contract.md).
-///
-public static class OperatorAnalyticsEndpoints
-{
- // Префикс группы аналитики (Ruling 4 этапа 10).
- private const string AnalyticsGroupPrefix = "/api/operator/analytics";
-
- // OpenAPI-тег группы.
- private const string OperatorOpenApiTag = "operator";
-
- // Группировка расхода токенов по умолчанию (сутки).
- private const string DefaultGroupBy = TokenUsageGroupBys.Day;
-
- // 400 tokens: неизвестная группировка.
- private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)";
-
- ///
- /// Регистрирует группу /api/operator/analytics: overview/tokens/activity.
- ///
- /// Построитель маршрутов приложения.
- /// Построитель маршрутов для цепочки вызовов.
- public static IEndpointRouteBuilder MapOperatorAnalyticsEndpoints(this IEndpointRouteBuilder app)
- {
- var group = app.MapGroup(AnalyticsGroupPrefix).WithTags(OperatorOpenApiTag);
- group.MapGet("/overview", OverviewAsync);
- group.MapGet("/tokens", TokensAsync);
- group.MapGet("/activity", ActivityAsync);
- group.MapGet("/suspicious", SuspiciousAsync);
- return app;
- }
-
- // GET /api/operator/analytics/suspicious?from=&to=: находки детектора подозрительной активности (§10.5).
- // from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа.
- // to: Конец окна анализа (включительно; ISO-8601); null — «сейчас».
- // context: Контекст запроса.
- // suspiciousService: Детектор подозрительной активности (scoped).
- // ct: Токен отмены.
- // Возвращает: 200 сводка находок или 401 без операторской сессии.
- private static async Task SuspiciousAsync(
- DateTimeOffset? from,
- DateTimeOffset? to,
- HttpContext context,
- SuspiciousActivityService suspiciousService,
- CancellationToken ct)
- {
- if (context.GetCurrentOperator() is null)
- {
- return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
- }
-
- SuspiciousActivityDto report = await suspiciousService.AnalyzeAsync(from, to, ct);
- return Results.Ok(report);
- }
-
- // GET /api/operator/analytics/overview?from=&to=: сводка (тенанты, токены, события, входы/выходы).
- // from: Начало периода (включительно; ISO-8601).
- // to: Конец периода (включительно; ISO-8601).
- // context: Контекст запроса.
- // analyticsService: Сервис аналитики (scoped).
- // ct: Токен отмены.
- // Возвращает: 200 сводка или 401 без операторской сессии.
- private static async Task OverviewAsync(
- DateTimeOffset? from,
- DateTimeOffset? to,
- HttpContext context,
- AnalyticsService analyticsService,
- CancellationToken ct)
- {
- if (context.GetCurrentOperator() is null)
- {
- return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
- }
-
- AnalyticsOverviewDto overview = await analyticsService.OverviewAsync(from, to, ct);
- return Results.Ok(overview);
- }
-
- // GET /api/operator/analytics/tokens?groupBy=&tenantId=&from=&to=: агрегаты расхода токенов.
- // groupBy: Группировка day|tenant|provider|model (дефолт day).
- // tenantId: Тенант (равенство; пусто — все тенанты).
- // from: Начало периода (включительно; ISO-8601).
- // to: Конец периода (включительно; ISO-8601).
- // context: Контекст запроса.
- // analyticsService: Сервис аналитики (scoped).
- // ct: Токен отмены.
- // Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии.
- private static async Task TokensAsync(
- string? groupBy,
- Guid? tenantId,
- DateTimeOffset? from,
- DateTimeOffset? to,
- HttpContext context,
- AnalyticsService analyticsService,
- CancellationToken ct)
- {
- if (context.GetCurrentOperator() is null)
- {
- return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
- }
-
- string normalizedGroupBy = string.IsNullOrWhiteSpace(groupBy) ? DefaultGroupBy : groupBy;
- if (!IsKnownGroupBy(normalizedGroupBy))
- {
- return EndpointResults.BadRequest(InvalidGroupByDetail);
- }
-
- AnalyticsTokensDto tokens = await analyticsService.TokensAsync(normalizedGroupBy, tenantId, from, to, ct);
- return Results.Ok(tokens);
- }
-
- // GET /api/operator/analytics/activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента действий.
- // eventType: Тип события (равенство; пусто — без фильтра).
- // actorType: Тип актора operator|tenant|system (равенство).
- // actorId: Идентификатор актора (равенство).
- // tenantId: Тенант (равенство).
- // from: Нижняя граница At (включительно; ISO-8601).
- // to: Верхняя граница At (включительно; ISO-8601).
- // limit: Размер страницы (дефолт 100, кламп 1..500).
- // offset: Смещение страницы (≥0).
- // context: Контекст запроса.
- // analyticsService: Сервис аналитики (scoped).
- // ct: Токен отмены.
- // Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии.
- private static async Task ActivityAsync(
- string? eventType,
- string? actorType,
- Guid? actorId,
- Guid? tenantId,
- DateTimeOffset? from,
- DateTimeOffset? to,
- int? limit,
- int? offset,
- HttpContext context,
- AnalyticsService analyticsService,
- CancellationToken ct)
- {
- if (context.GetCurrentOperator() is null)
- {
- return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
- }
-
- AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
- eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
- return Results.Ok(activity);
- }
-
- ///
- /// Известна ли группировка расхода токенов (day|tenant|provider|model).
- ///
- /// Значение группировки.
- /// True — поддерживаемая группировка.
- public static bool IsKnownGroupBy(string groupBy) =>
- groupBy is TokenUsageGroupBys.Day or TokenUsageGroupBys.Tenant
- or TokenUsageGroupBys.Provider or TokenUsageGroupBys.Model;
-}
+using Deal.Api.Http;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
+
+namespace Deal.Api.Endpoints;
+
+///
+/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3).
+///
+///
+/// Только под операторской сессией: без неё 401 «Требуется вход оператора» (как прочие /api/operator/*).
+/// Ничего не меняет (read-only). groupBy — day|tenant|provider|model (неизвестное — 400 {detail}); from/to —
+/// ISO-8601 (включительно), как у аудита; activity поддерживает фильтры eventType/actorType/actorId/tenantId,
+/// limit (1..500) и offset. Все ответы — camelCase (контракт: docs/architecture/2026-09-10-operator-analytics-contract.md).
+///
+public static class OperatorAnalyticsEndpoints
+{
+ // Префикс группы аналитики (Ruling 4 этапа 10).
+ private const string AnalyticsGroupPrefix = "/api/operator/analytics";
+
+ // OpenAPI-тег группы.
+ private const string OperatorOpenApiTag = "operator";
+
+ // Группировка расхода токенов по умолчанию (сутки).
+ private const string DefaultGroupBy = TokenUsageGroupBys.Day;
+
+ // 400 tokens: неизвестная группировка.
+ private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)";
+
+ ///
+ /// Регистрирует группу /api/operator/analytics: overview/tokens/activity.
+ ///
+ /// Построитель маршрутов приложения.
+ /// Построитель маршрутов для цепочки вызовов.
+ public static IEndpointRouteBuilder MapOperatorAnalyticsEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup(AnalyticsGroupPrefix).WithTags(OperatorOpenApiTag);
+ group.MapGet("/overview", OverviewAsync);
+ group.MapGet("/tokens", TokensAsync);
+ group.MapGet("/activity", ActivityAsync);
+ group.MapGet("/suspicious", SuspiciousAsync);
+ return app;
+ }
+
+ // GET /api/operator/analytics/suspicious?from=&to=: находки детектора подозрительной активности (§10.5).
+ // from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа.
+ // to: Конец окна анализа (включительно; ISO-8601); null — «сейчас».
+ // context: Контекст запроса.
+ // suspiciousService: Детектор подозрительной активности (scoped).
+ // ct: Токен отмены.
+ // Возвращает: 200 сводка находок или 401 без операторской сессии.
+ private static async Task SuspiciousAsync(
+ DateTimeOffset? from,
+ DateTimeOffset? to,
+ HttpContext context,
+ SuspiciousActivityService suspiciousService,
+ CancellationToken ct)
+ {
+ if (context.GetCurrentOperator() is null)
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
+ }
+
+ SuspiciousActivityDto report = await suspiciousService.AnalyzeAsync(from, to, ct);
+ return Results.Ok(report);
+ }
+
+ // GET /api/operator/analytics/overview?from=&to=: сводка (тенанты, токены, события, входы/выходы).
+ // from: Начало периода (включительно; ISO-8601).
+ // to: Конец периода (включительно; ISO-8601).
+ // context: Контекст запроса.
+ // analyticsService: Сервис аналитики (scoped).
+ // ct: Токен отмены.
+ // Возвращает: 200 сводка или 401 без операторской сессии.
+ private static async Task OverviewAsync(
+ DateTimeOffset? from,
+ DateTimeOffset? to,
+ HttpContext context,
+ AnalyticsService analyticsService,
+ CancellationToken ct)
+ {
+ if (context.GetCurrentOperator() is null)
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
+ }
+
+ AnalyticsOverviewDto overview = await analyticsService.OverviewAsync(from, to, ct);
+ return Results.Ok(overview);
+ }
+
+ // GET /api/operator/analytics/tokens?groupBy=&tenantId=&from=&to=: агрегаты расхода токенов.
+ // groupBy: Группировка day|tenant|provider|model (дефолт day).
+ // tenantId: Тенант (равенство; пусто — все тенанты).
+ // from: Начало периода (включительно; ISO-8601).
+ // to: Конец периода (включительно; ISO-8601).
+ // context: Контекст запроса.
+ // analyticsService: Сервис аналитики (scoped).
+ // ct: Токен отмены.
+ // Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии.
+ private static async Task TokensAsync(
+ string? groupBy,
+ Guid? tenantId,
+ DateTimeOffset? from,
+ DateTimeOffset? to,
+ HttpContext context,
+ AnalyticsService analyticsService,
+ CancellationToken ct)
+ {
+ if (context.GetCurrentOperator() is null)
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
+ }
+
+ string normalizedGroupBy = string.IsNullOrWhiteSpace(groupBy) ? DefaultGroupBy : groupBy;
+ if (!IsKnownGroupBy(normalizedGroupBy))
+ {
+ return EndpointResults.BadRequest(InvalidGroupByDetail);
+ }
+
+ AnalyticsTokensDto tokens = await analyticsService.TokensAsync(normalizedGroupBy, tenantId, from, to, ct);
+ return Results.Ok(tokens);
+ }
+
+ // GET /api/operator/analytics/activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента действий.
+ // eventType: Тип события (равенство; пусто — без фильтра).
+ // actorType: Тип актора operator|tenant|system (равенство).
+ // actorId: Идентификатор актора (равенство).
+ // tenantId: Тенант (равенство).
+ // from: Нижняя граница At (включительно; ISO-8601).
+ // to: Верхняя граница At (включительно; ISO-8601).
+ // limit: Размер страницы (дефолт 100, кламп 1..500).
+ // offset: Смещение страницы (≥0).
+ // context: Контекст запроса.
+ // analyticsService: Сервис аналитики (scoped).
+ // ct: Токен отмены.
+ // Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии.
+ private static async Task ActivityAsync(
+ string? eventType,
+ string? actorType,
+ Guid? actorId,
+ Guid? tenantId,
+ DateTimeOffset? from,
+ DateTimeOffset? to,
+ int? limit,
+ int? offset,
+ HttpContext context,
+ AnalyticsService analyticsService,
+ CancellationToken ct)
+ {
+ if (context.GetCurrentOperator() is null)
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
+ }
+
+ AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
+ eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
+ return Results.Ok(activity);
+ }
+
+ ///
+ /// Известна ли группировка расхода токенов (day|tenant|provider|model).
+ ///
+ /// Значение группировки.
+ /// True — поддерживаемая группировка.
+ public static bool IsKnownGroupBy(string groupBy) =>
+ groupBy is TokenUsageGroupBys.Day or TokenUsageGroupBys.Tenant
+ or TokenUsageGroupBys.Provider or TokenUsageGroupBys.Model;
+}
diff --git a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs
index ab3cbe5..bcab5ed 100644
--- a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs
@@ -1,6 +1,9 @@
using Deal.Api.Http;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs
index 35bf1b3..b26be34 100644
--- a/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs
@@ -1,171 +1,174 @@
-using Deal.Api.Http;
-using Deal.Api.Middleware;
-using Deal.Modules.Tenants.Application;
-using Deal.Modules.Tenants.Application.Models;
-using Microsoft.Extensions.Options;
-using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
-// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами.
-using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
-
-namespace Deal.Api.Endpoints;
-
-///
-/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1).
-///
-///
-/// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore),
-/// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax).
-/// Успех-ответы — {ok:true,...}, ошибки — HTTP-код + {"detail":"..."} (Ruling 10). Защищённые
-/// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит.
-/// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4).
-///
-public static class OperatorAuthEndpoints
-{
- private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
- private const string OperatorAuthGroupPrefix = "/api/operator/auth";
- private const string OperatorAuthOpenApiTag = "operator-auth";
-
- ///
- /// Регистрирует группу /api/operator/auth: login, logout, me.
- ///
- /// Построитель маршрутов приложения.
- /// Построитель маршрутов для цепочки вызовов.
- public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app)
- {
- var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag);
-
- // Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки
- // входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
- group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
- group.MapPost("/logout", LogoutAsync);
- group.MapGet("/me", MeAsync);
-
- return app;
- }
-
- // POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4).
- // До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
- private static async Task LoginAsync(
- LoginRequest body,
- OperatorAuthService operatorAuthService,
- AuditService auditService,
- IOptions cookieOptions,
- HttpContext context,
- CancellationToken ct,
- LoginAttemptGuard loginAttemptGuard)
- {
- string? attemptedLogin = NormalizeLogin(body.Login);
-
- // Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа
- // ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен).
- if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
- {
- return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
- }
-
- var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct);
- if (result.Login is null || result.Token is null)
- {
- // Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints).
- // Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4);
- // счётчик неудач гварда растёт там же (пустые логины ключа не имеют).
- if (attemptedLogin is not null)
- {
- await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
- await auditService.AppendAsync(new AuditRecordDto(
- AuditEvents.OperatorLoginFailed,
- AuditActorTypes.Operator,
- ActorId: null,
- TenantId: null,
- Ip: ClientIp(context),
- DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
- }
-
- return EndpointResults.Unauthorized(InvalidCredentialsDetail);
- }
-
- // Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5).
- await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
-
- await auditService.AppendAsync(new AuditRecordDto(
- AuditEvents.OperatorLoginOk,
- AuditActorTypes.Operator,
- ActorId: result.OperatorId,
- TenantId: null,
- Ip: ClientIp(context),
- DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
-
- SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
- return Results.Ok(new { ok = true, login = result.Login });
- }
-
- // POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok).
- private static async Task LogoutAsync(
- OperatorAuthService operatorAuthService,
- IOptions cookieOptions,
- HttpContext context,
- CancellationToken ct)
- {
- var cookieName = cookieOptions.Value.Name;
- var rawToken = context.Request.Cookies[cookieName];
- // Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items).
- CurrentOperator? operatorIdentity = context.GetCurrentOperator();
- await operatorAuthService.LogoutAsync(rawToken, ct);
- context.Response.Cookies.Delete(cookieName);
-
- // Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии.
- if (operatorIdentity is not null)
- {
- await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
- }
-
- return Results.Ok(new { ok = true });
- }
-
- // GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1).
- private static IResult MeAsync(HttpContext context)
- {
- var operatorIdentity = context.GetCurrentOperator();
- if (operatorIdentity is null)
- {
- return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
- }
-
- return Results.Ok(new { login = operatorIdentity.Login, ok = true });
- }
-
- // Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига.
- // context: Контекст запроса.
- // options: Настройки куки из конфигурации (секция OperatorCookies).
- // rawToken: Raw-токен операторской сессии.
- private static void SetOperatorSessionCookie(HttpContext context, OperatorCookieOptions options, string rawToken)
- {
- // MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на
- // OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions).
- context.Response.Cookies.Append(
- options.Name,
- rawToken,
- new AspNetCoreCookieOptions
- {
- HttpOnly = true,
- SameSite = SameSiteMode.Lax,
- Path = "/",
- MaxAge = TimeSpan.FromHours(options.Hours),
- Secure = options.Secure,
- });
- }
-
- // Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего.
- // login: Логин из тела запроса.
- // Возвращает: Нормализованный логин или null при пустом/пробельном входе.
- private static string? NormalizeLogin(string? login)
- {
- string? normalized = login?.Trim().ToLowerInvariant();
- return string.IsNullOrEmpty(normalized) ? null : normalized;
- }
-
- // IP-адрес клиента для аудита (без порта; null, если недоступен).
- // context: Контекст запроса.
- // Возвращает: Строковое представление IP или null.
- private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
-}
+using Deal.Api.Http;
+using Deal.Api.Middleware;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
+using Microsoft.Extensions.Options;
+using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
+// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами.
+using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
+
+namespace Deal.Api.Endpoints;
+
+///
+/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1).
+///
+///
+/// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore),
+/// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax).
+/// Успех-ответы — {ok:true,...}, ошибки — HTTP-код + {"detail":"..."} (Ruling 10). Защищённые
+/// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит.
+/// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4).
+///
+public static class OperatorAuthEndpoints
+{
+ private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
+ private const string OperatorAuthGroupPrefix = "/api/operator/auth";
+ private const string OperatorAuthOpenApiTag = "operator-auth";
+
+ ///
+ /// Регистрирует группу /api/operator/auth: login, logout, me.
+ ///
+ /// Построитель маршрутов приложения.
+ /// Построитель маршрутов для цепочки вызовов.
+ public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag);
+
+ // Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки
+ // входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
+ group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
+ group.MapPost("/logout", LogoutAsync);
+ group.MapGet("/me", MeAsync);
+
+ return app;
+ }
+
+ // POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4).
+ // До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
+ private static async Task LoginAsync(
+ LoginRequest body,
+ OperatorAuthService operatorAuthService,
+ AuditService auditService,
+ IOptions cookieOptions,
+ HttpContext context,
+ CancellationToken ct,
+ LoginAttemptGuard loginAttemptGuard)
+ {
+ string? attemptedLogin = NormalizeLogin(body.Login);
+
+ // Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа
+ // ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен).
+ if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
+ {
+ return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
+ }
+
+ var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct);
+ if (result.Login is null || result.Token is null)
+ {
+ // Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints).
+ // Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4);
+ // счётчик неудач гварда растёт там же (пустые логины ключа не имеют).
+ if (attemptedLogin is not null)
+ {
+ await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
+ await auditService.AppendAsync(new AuditRecordDto(
+ AuditEvents.OperatorLoginFailed,
+ AuditActorTypes.Operator,
+ ActorId: null,
+ TenantId: null,
+ Ip: ClientIp(context),
+ DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
+ }
+
+ return EndpointResults.Unauthorized(InvalidCredentialsDetail);
+ }
+
+ // Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5).
+ await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
+
+ await auditService.AppendAsync(new AuditRecordDto(
+ AuditEvents.OperatorLoginOk,
+ AuditActorTypes.Operator,
+ ActorId: result.OperatorId,
+ TenantId: null,
+ Ip: ClientIp(context),
+ DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
+
+ SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
+ return Results.Ok(new { ok = true, login = result.Login });
+ }
+
+ // POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok).
+ private static async Task LogoutAsync(
+ OperatorAuthService operatorAuthService,
+ IOptions cookieOptions,
+ HttpContext context,
+ CancellationToken ct)
+ {
+ var cookieName = cookieOptions.Value.Name;
+ var rawToken = context.Request.Cookies[cookieName];
+ // Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items).
+ CurrentOperator? operatorIdentity = context.GetCurrentOperator();
+ await operatorAuthService.LogoutAsync(rawToken, ct);
+ context.Response.Cookies.Delete(cookieName);
+
+ // Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии.
+ if (operatorIdentity is not null)
+ {
+ await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
+ }
+
+ return Results.Ok(new { ok = true });
+ }
+
+ // GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1).
+ private static IResult MeAsync(HttpContext context)
+ {
+ var operatorIdentity = context.GetCurrentOperator();
+ if (operatorIdentity is null)
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
+ }
+
+ return Results.Ok(new { login = operatorIdentity.Login, ok = true });
+ }
+
+ // Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига.
+ // context: Контекст запроса.
+ // options: Настройки куки из конфигурации (секция OperatorCookies).
+ // rawToken: Raw-токен операторской сессии.
+ private static void SetOperatorSessionCookie(HttpContext context, OperatorCookieOptions options, string rawToken)
+ {
+ // MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на
+ // OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions).
+ context.Response.Cookies.Append(
+ options.Name,
+ rawToken,
+ new AspNetCoreCookieOptions
+ {
+ HttpOnly = true,
+ SameSite = SameSiteMode.Lax,
+ Path = "/",
+ MaxAge = TimeSpan.FromHours(options.Hours),
+ Secure = options.Secure,
+ });
+ }
+
+ // Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего.
+ // login: Логин из тела запроса.
+ // Возвращает: Нормализованный логин или null при пустом/пробельном входе.
+ private static string? NormalizeLogin(string? login)
+ {
+ string? normalized = login?.Trim().ToLowerInvariant();
+ return string.IsNullOrEmpty(normalized) ? null : normalized;
+ }
+
+ // IP-адрес клиента для аудита (без порта; null, если недоступен).
+ // context: Контекст запроса.
+ // Возвращает: Строковое представление IP или null.
+ private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
+}
diff --git a/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs
index 1e548e9..5485a70 100644
--- a/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs
@@ -1,6 +1,9 @@
using Deal.Api.Http;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs
index 8d310dd..1464661 100644
--- a/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs
@@ -1,6 +1,9 @@
using Deal.Api.Http;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs
index 02b0798..027fddf 100644
--- a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs
@@ -1,154 +1,157 @@
-using Deal.Api.Endpoints.RequestModels;
-using Deal.Api.Http;
-using Deal.Api.Telegram;
-using Deal.Modules.Tenants.Application;
-using Deal.Modules.Tenants.Application.Models;
-
-namespace Deal.Api.Endpoints;
-
-///
-/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram
-/// (ТЗ §4.1/§8.1).
-///
-///
-/// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram
-/// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт.
-///
-/// - GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт),
-/// apiHash (маска) и keysSet;
-/// - PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно
-/// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой),
-/// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях).
-///
-/// Ошибки — 400/401 {detail} (формат прототипа, Ruling 10).
-///
-public static class OperatorSettingsEndpoints
-{
- // Префикс группы операторских настроек.
- private const string SettingsGroupPrefix = "/api/operator/settings";
-
- // OpenAPI-тег группы.
- private const string SettingsOpenApiTag = "operator-settings";
-
- // Относительный путь глобальных ключей Telegram (GET/PUT).
- private const string TelegramKeysPath = "/telegram-keys";
-
- // Текст 400: пустое тело PUT (ни одного поля).
- private const string EmptyBodyDetail = "Укажите api_id и api_hash";
-
- // Текст 400: частичное обновление, но ключей ещё нет — нужны оба поля.
- private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash";
-
- // Текст 400: api_id не 5..9 цифр.
- private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр";
-
- // Текст 400: api_hash пустой/маска/с префиксом enc:.
- private const string InvalidApiHashDetail = "Укажите непустой api_hash";
-
- ///
- /// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT).
- ///
- /// Построитель маршрутов приложения.
- /// Построитель маршрутов для цепочки вызовов.
- public static IEndpointRouteBuilder MapOperatorSettingsEndpoints(this IEndpointRouteBuilder app)
- {
- var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag);
- group.MapGet(TelegramKeysPath, GetTelegramKeysAsync);
- group.MapPut(TelegramKeysPath, PutTelegramKeysAsync);
- return app;
- }
-
- // GET /api/operator/settings/telegram-keys: маскированные глобальные ключи Telegram.
- // context: Контекст запроса.
- // keys: Сервис глобальных ключей Telegram (scoped).
- // ct: Токен отмены.
- // Возвращает: 200 маскированный снимок или 401 без операторской сессии.
- private static async Task GetTelegramKeysAsync(
- HttpContext context,
- TelegramKeysService keys,
- CancellationToken ct)
- {
- if (context.GetCurrentOperator() is null)
- {
- return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
- }
-
- TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
- return Results.Ok(snapshot);
- }
-
- // PUT /api/operator/settings/telegram-keys: частичное сохранение глобальных ключей Telegram
- // оператором.
- // Поля можно передавать по отдельности: непереданное поле (null) сохраняет текущее значение,
- // явное значение (в т.ч. пустая строка) валидируется. Если ключей ещё нет, оба поля обязательны.
- // body: Тело {apiId?, apiHash?} (хотя бы одно поле).
- // context: Контекст запроса.
- // keys: Сервис глобальных ключей Telegram (scoped).
- // auditService: Сервис аудита (событие telegram_keys_changed).
- // ct: Токен отмены.
- // Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии.
- private static async Task PutTelegramKeysAsync(
- OperatorTelegramKeysRequest? body,
- HttpContext context,
- TelegramKeysService keys,
- AuditService auditService,
- CancellationToken ct)
- {
- var operatorIdentity = context.GetCurrentOperator();
- if (operatorIdentity is null)
- {
- return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
- }
-
- if (body is null)
- {
- return EndpointResults.BadRequest(EmptyBodyDetail);
- }
-
- // null — поле не передано (сохраняем текущее); непустая строка/плейсхолдер — валидируем явно.
- string? apiId = body.ApiId?.Trim();
- string? apiHash = body.ApiHash?.Trim();
- if (apiId is null && apiHash is null)
- {
- return EndpointResults.BadRequest(EmptyBodyDetail);
- }
-
- if (apiId is not null && !TelegramKeysService.IsValidApiId(apiId))
- {
- return EndpointResults.BadRequest(InvalidApiIdDetail);
- }
-
- if (apiHash is not null && !TelegramKeysService.IsValidApiHash(apiHash))
- {
- return EndpointResults.BadRequest(InvalidApiHashDetail);
- }
-
- // Частичное обновление: недостающее поле берём из текущих ключей; если ключей ещё нет — нужны оба.
- TgKeysSnapshot current = await keys.GetAsync(ct);
- string effectiveApiId = apiId ?? current.ApiId;
- string effectiveApiHash = apiHash ?? current.ApiHash;
- if (effectiveApiId.Length == 0 || effectiveApiHash.Length == 0)
- {
- return EndpointResults.BadRequest(MissingKeysDetail);
- }
-
- await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct);
-
- // Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4).
- await auditService.AppendAsync(new AuditRecordDto(
- AuditEvents.TelegramKeysChanged,
- AuditActorTypes.Operator,
- ActorId: operatorIdentity.OperatorId,
- TenantId: null,
- Ip: ClientIp(context),
- DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct);
-
- TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
- return Results.Ok(snapshot);
- }
-
- // IP-адрес клиента для аудита (без порта; null, если недоступен).
- // context: Контекст запроса.
- // Возвращает: Строковое представление IP или null.
- private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
-}
+using Deal.Api.Endpoints.RequestModels;
+using Deal.Api.Http;
+using Deal.Api.Telegram;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
+
+namespace Deal.Api.Endpoints;
+
+///
+/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram
+/// (ТЗ §4.1/§8.1).
+///
+///
+/// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram
+/// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт.
+///
+/// - GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт),
+/// apiHash (маска) и keysSet;
+/// - PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно
+/// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой),
+/// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях).
+///
+/// Ошибки — 400/401 {detail} (формат прототипа, Ruling 10).
+///
+public static class OperatorSettingsEndpoints
+{
+ // Префикс группы операторских настроек.
+ private const string SettingsGroupPrefix = "/api/operator/settings";
+
+ // OpenAPI-тег группы.
+ private const string SettingsOpenApiTag = "operator-settings";
+
+ // Относительный путь глобальных ключей Telegram (GET/PUT).
+ private const string TelegramKeysPath = "/telegram-keys";
+
+ // Текст 400: пустое тело PUT (ни одного поля).
+ private const string EmptyBodyDetail = "Укажите api_id и api_hash";
+
+ // Текст 400: частичное обновление, но ключей ещё нет — нужны оба поля.
+ private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash";
+
+ // Текст 400: api_id не 5..9 цифр.
+ private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр";
+
+ // Текст 400: api_hash пустой/маска/с префиксом enc:.
+ private const string InvalidApiHashDetail = "Укажите непустой api_hash";
+
+ ///
+ /// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT).
+ ///
+ /// Построитель маршрутов приложения.
+ /// Построитель маршрутов для цепочки вызовов.
+ public static IEndpointRouteBuilder MapOperatorSettingsEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag);
+ group.MapGet(TelegramKeysPath, GetTelegramKeysAsync);
+ group.MapPut(TelegramKeysPath, PutTelegramKeysAsync);
+ return app;
+ }
+
+ // GET /api/operator/settings/telegram-keys: маскированные глобальные ключи Telegram.
+ // context: Контекст запроса.
+ // keys: Сервис глобальных ключей Telegram (scoped).
+ // ct: Токен отмены.
+ // Возвращает: 200 маскированный снимок или 401 без операторской сессии.
+ private static async Task GetTelegramKeysAsync(
+ HttpContext context,
+ TelegramKeysService keys,
+ CancellationToken ct)
+ {
+ if (context.GetCurrentOperator() is null)
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
+ }
+
+ TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
+ return Results.Ok(snapshot);
+ }
+
+ // PUT /api/operator/settings/telegram-keys: частичное сохранение глобальных ключей Telegram
+ // оператором.
+ // Поля можно передавать по отдельности: непереданное поле (null) сохраняет текущее значение,
+ // явное значение (в т.ч. пустая строка) валидируется. Если ключей ещё нет, оба поля обязательны.
+ // body: Тело {apiId?, apiHash?} (хотя бы одно поле).
+ // context: Контекст запроса.
+ // keys: Сервис глобальных ключей Telegram (scoped).
+ // auditService: Сервис аудита (событие telegram_keys_changed).
+ // ct: Токен отмены.
+ // Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии.
+ private static async Task PutTelegramKeysAsync(
+ OperatorTelegramKeysRequest? body,
+ HttpContext context,
+ TelegramKeysService keys,
+ AuditService auditService,
+ CancellationToken ct)
+ {
+ var operatorIdentity = context.GetCurrentOperator();
+ if (operatorIdentity is null)
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
+ }
+
+ if (body is null)
+ {
+ return EndpointResults.BadRequest(EmptyBodyDetail);
+ }
+
+ // null — поле не передано (сохраняем текущее); непустая строка/плейсхолдер — валидируем явно.
+ string? apiId = body.ApiId?.Trim();
+ string? apiHash = body.ApiHash?.Trim();
+ if (apiId is null && apiHash is null)
+ {
+ return EndpointResults.BadRequest(EmptyBodyDetail);
+ }
+
+ if (apiId is not null && !TelegramKeysService.IsValidApiId(apiId))
+ {
+ return EndpointResults.BadRequest(InvalidApiIdDetail);
+ }
+
+ if (apiHash is not null && !TelegramKeysService.IsValidApiHash(apiHash))
+ {
+ return EndpointResults.BadRequest(InvalidApiHashDetail);
+ }
+
+ // Частичное обновление: недостающее поле берём из текущих ключей; если ключей ещё нет — нужны оба.
+ TgKeysSnapshot current = await keys.GetAsync(ct);
+ string effectiveApiId = apiId ?? current.ApiId;
+ string effectiveApiHash = apiHash ?? current.ApiHash;
+ if (effectiveApiId.Length == 0 || effectiveApiHash.Length == 0)
+ {
+ return EndpointResults.BadRequest(MissingKeysDetail);
+ }
+
+ await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct);
+
+ // Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4).
+ await auditService.AppendAsync(new AuditRecordDto(
+ AuditEvents.TelegramKeysChanged,
+ AuditActorTypes.Operator,
+ ActorId: operatorIdentity.OperatorId,
+ TenantId: null,
+ Ip: ClientIp(context),
+ DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct);
+
+ TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
+ return Results.Ok(snapshot);
+ }
+
+ // IP-адрес клиента для аудита (без порта; null, если недоступен).
+ // context: Контекст запроса.
+ // Возвращает: Строковое представление IP или null.
+ private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
+}
diff --git a/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs
index 43668b6..dd27c5f 100644
--- a/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs
@@ -1,6 +1,9 @@
using Deal.Api.Http;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Microsoft.Extensions.Options;
using CookieOptions = Deal.Api.Configuration.CookieOptions;
diff --git a/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs b/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs
index e1191ae..4b40095 100644
--- a/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs
@@ -1,7 +1,9 @@
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
-using Deal.Modules.Pipeline.Application;
+using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
+using Deal.Modules.Pipeline.Application.Registrars;
+using Deal.Modules.Pipeline.Application.Services;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Endpoints/RatesEndpoints.cs b/src/core/Deal.Api/Endpoints/RatesEndpoints.cs
index 7057aeb..b19bb5c 100644
--- a/src/core/Deal.Api/Endpoints/RatesEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/RatesEndpoints.cs
@@ -1,6 +1,8 @@
using Deal.Api.Http;
-using Deal.Modules.Settings.Application;
+using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs
index f722173..e1b74f7 100644
--- a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs
@@ -1,9 +1,15 @@
using System.Text.Json;
using Deal.Api;
using Deal.Api.Http;
-using Deal.Modules.Settings.Application;
+using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs b/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs
index 78caaa3..645dcbd 100644
--- a/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs
@@ -5,7 +5,11 @@ using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Telegram.Application.Models;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
diff --git a/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs b/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs
index 1a39e93..b2e56a7 100644
--- a/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs
+++ b/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs
@@ -1,6 +1,9 @@
using Deal.Api.Events;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Hosting;
diff --git a/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs b/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs
index ded666c..fefab2d 100644
--- a/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs
+++ b/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs
@@ -1,5 +1,9 @@
using Deal.Api.Configuration;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Hosting;
diff --git a/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs b/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs
index 99a5ea1..1561bb6 100644
--- a/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs
+++ b/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs
@@ -1,6 +1,14 @@
-using Deal.Modules.Discovery.Application;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Discovery.Application.Abstractions;
+using Deal.Modules.Discovery.Application.Exceptions;
+using Deal.Modules.Discovery.Application.Extensions;
+using Deal.Modules.Discovery.Application.Models;
+using Deal.Modules.Discovery.Application.Registrars;
+using Deal.Modules.Discovery.Application.Services;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants;
namespace Deal.Api.Hosting;
diff --git a/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs b/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs
index e758695..a3e1d8a 100644
--- a/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs
+++ b/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs
@@ -1,8 +1,14 @@
using Deal.Infrastructure.Integrations;
-using Deal.Modules.Kanban.Application;
+using Deal.Modules.Kanban.Application.Abstractions;
+using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Kanban.Application.Registrars;
+using Deal.Modules.Kanban.Application.Services;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants;
namespace Deal.Api.Hosting;
diff --git a/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs b/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs
index a9c0b88..c168a21 100644
--- a/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs
+++ b/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs
@@ -1,4 +1,8 @@
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Hosting;
diff --git a/src/core/Deal.Api/Hosting/StorageTickScheduler.cs b/src/core/Deal.Api/Hosting/StorageTickScheduler.cs
index 40beec3..2586bf5 100644
--- a/src/core/Deal.Api/Hosting/StorageTickScheduler.cs
+++ b/src/core/Deal.Api/Hosting/StorageTickScheduler.cs
@@ -1,9 +1,18 @@
using Deal.Api.Events;
-using Deal.Modules.Kanban.Application;
+using Deal.Modules.Kanban.Application.Abstractions;
+using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
-using Deal.Modules.Pipeline.Application;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Kanban.Application.Registrars;
+using Deal.Modules.Kanban.Application.Services;
+using Deal.Modules.Pipeline.Application.Abstractions;
+using Deal.Modules.Pipeline.Application.Models;
+using Deal.Modules.Pipeline.Application.Registrars;
+using Deal.Modules.Pipeline.Application.Services;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants;
namespace Deal.Api.Hosting;
diff --git a/src/core/Deal.Api/Hosting/TenantBootstrapService.cs b/src/core/Deal.Api/Hosting/TenantBootstrapService.cs
index f3570f7..da60f11 100644
--- a/src/core/Deal.Api/Hosting/TenantBootstrapService.cs
+++ b/src/core/Deal.Api/Hosting/TenantBootstrapService.cs
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Tenancy;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Hosting;
diff --git a/src/core/Deal.Api/Http/AuditAppender.cs b/src/core/Deal.Api/Http/AuditAppender.cs
index 068e86a..823e41d 100644
--- a/src/core/Deal.Api/Http/AuditAppender.cs
+++ b/src/core/Deal.Api/Http/AuditAppender.cs
@@ -1,83 +1,86 @@
-using Deal.Modules.Tenants.Application;
-using Deal.Modules.Tenants.Application.Models;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Deal.Api.Http;
-
-///
-/// Хелпер записи аудита действий пользователей тенанта и операторов (единая точка — ).
-///
-///
-/// Актор берётся из разрешённой сессии (HttpContext.Items, наполняют SessionMiddleware/
-/// OperatorSessionMiddleware): для тенанта — с userId/tenantId, для
-/// оператора — без tenantId. IP — адрес клиента без порта.
-/// Детали — минимальные, без секретов (пароли/токены/api-ключи). Нет сессии — no-op (вызывать после
-/// проверки HasUser, но безопасно и без неё). Append-only, как весь аудит.
-///
-public static class AuditAppender
-{
- ///
- /// Пишет событие действия пользователя тенанта (актор tenant).
- ///
- /// Контекст запроса (источник актора и IP).
- /// Тип события — константа .
- /// Минимальные детали события (обычно анонимный объект) или null.
- /// Токен отмены.
- public static async Task AppendTenantAsync(HttpContext context, string eventType, object? details, CancellationToken ct)
- {
- CurrentUser? user = context.GetCurrentUser();
- if (user is null)
- {
- return;
- }
-
- AuditService audit = context.RequestServices.GetRequiredService();
- await audit.AppendAsync(
- new AuditRecordDto(
- eventType,
- AuditActorTypes.Tenant,
- ActorId: user.UserId,
- TenantId: user.TenantId,
- Ip: ClientIp(context),
- DetailJson: DetailJson(details)),
- ct);
- }
-
- ///
- /// Пишет событие действия оператора (актор operator, без tenantId).
- ///
- /// Контекст запроса (источник актора и IP).
- /// Тип события — константа .
- /// Минимальные детали события (обычно анонимный объект) или null.
- /// Токен отмены.
- public static async Task AppendOperatorAsync(HttpContext context, string eventType, object? details, CancellationToken ct)
- {
- CurrentOperator? operatorIdentity = context.GetCurrentOperator();
- if (operatorIdentity is null)
- {
- return;
- }
-
- AuditService audit = context.RequestServices.GetRequiredService();
- await audit.AppendAsync(
- new AuditRecordDto(
- eventType,
- AuditActorTypes.Operator,
- ActorId: operatorIdentity.OperatorId,
- TenantId: null,
- Ip: ClientIp(context),
- DetailJson: DetailJson(details)),
- ct);
- }
-
- // Сериализует детали события (null — деталей нет).
- // details: Объект деталей или null.
- // Возвращает: JSON деталей (camelCase) или null.
- private static string? DetailJson(object? details) =>
- details is null ? null : AuditService.ToDetailJson(details);
-
- // IP-адрес клиента для аудита (без порта; null, если недоступен).
- // context: Контекст запроса.
- // Возвращает: Строковое представление IP или null.
- private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
-}
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Deal.Api.Http;
+
+///
+/// Хелпер записи аудита действий пользователей тенанта и операторов (единая точка — ).
+///
+///
+/// Актор берётся из разрешённой сессии (HttpContext.Items, наполняют SessionMiddleware/
+/// OperatorSessionMiddleware): для тенанта — с userId/tenantId, для
+/// оператора — без tenantId. IP — адрес клиента без порта.
+/// Детали — минимальные, без секретов (пароли/токены/api-ключи). Нет сессии — no-op (вызывать после
+/// проверки HasUser, но безопасно и без неё). Append-only, как весь аудит.
+///
+public static class AuditAppender
+{
+ ///
+ /// Пишет событие действия пользователя тенанта (актор tenant).
+ ///
+ /// Контекст запроса (источник актора и IP).
+ /// Тип события — константа .
+ /// Минимальные детали события (обычно анонимный объект) или null.
+ /// Токен отмены.
+ public static async Task AppendTenantAsync(HttpContext context, string eventType, object? details, CancellationToken ct)
+ {
+ CurrentUser? user = context.GetCurrentUser();
+ if (user is null)
+ {
+ return;
+ }
+
+ AuditService audit = context.RequestServices.GetRequiredService();
+ await audit.AppendAsync(
+ new AuditRecordDto(
+ eventType,
+ AuditActorTypes.Tenant,
+ ActorId: user.UserId,
+ TenantId: user.TenantId,
+ Ip: ClientIp(context),
+ DetailJson: DetailJson(details)),
+ ct);
+ }
+
+ ///
+ /// Пишет событие действия оператора (актор operator, без tenantId).
+ ///
+ /// Контекст запроса (источник актора и IP).
+ /// Тип события — константа .
+ /// Минимальные детали события (обычно анонимный объект) или null.
+ /// Токен отмены.
+ public static async Task AppendOperatorAsync(HttpContext context, string eventType, object? details, CancellationToken ct)
+ {
+ CurrentOperator? operatorIdentity = context.GetCurrentOperator();
+ if (operatorIdentity is null)
+ {
+ return;
+ }
+
+ AuditService audit = context.RequestServices.GetRequiredService();
+ await audit.AppendAsync(
+ new AuditRecordDto(
+ eventType,
+ AuditActorTypes.Operator,
+ ActorId: operatorIdentity.OperatorId,
+ TenantId: null,
+ Ip: ClientIp(context),
+ DetailJson: DetailJson(details)),
+ ct);
+ }
+
+ // Сериализует детали события (null — деталей нет).
+ // details: Объект деталей или null.
+ // Возвращает: JSON деталей (camelCase) или null.
+ private static string? DetailJson(object? details) =>
+ details is null ? null : AuditService.ToDetailJson(details);
+
+ // IP-адрес клиента для аудита (без порта; null, если недоступен).
+ // context: Контекст запроса.
+ // Возвращает: Строковое представление IP или null.
+ private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
+}
diff --git a/src/core/Deal.Api/Http/LoginAttemptGuard.cs b/src/core/Deal.Api/Http/LoginAttemptGuard.cs
index d805fac..b911766 100644
--- a/src/core/Deal.Api/Http/LoginAttemptGuard.cs
+++ b/src/core/Deal.Api/Http/LoginAttemptGuard.cs
@@ -1,5 +1,9 @@
using Deal.Api.Configuration;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Http;
diff --git a/src/core/Deal.Api/Http/StoreBackedFixedWindowRateLimiter.cs b/src/core/Deal.Api/Http/StoreBackedFixedWindowRateLimiter.cs
index ac8dc43..6d95bb5 100644
--- a/src/core/Deal.Api/Http/StoreBackedFixedWindowRateLimiter.cs
+++ b/src/core/Deal.Api/Http/StoreBackedFixedWindowRateLimiter.cs
@@ -1,5 +1,9 @@
using System.Threading.RateLimiting;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Deal.Api.Http;
diff --git a/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs b/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs
index d85a770..9333706 100644
--- a/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs
+++ b/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs
@@ -1,6 +1,10 @@
using Deal.Api.Configuration;
using Deal.Api.Http;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Microsoft.Extensions.Options;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
diff --git a/src/core/Deal.Api/Middleware/SessionMiddleware.cs b/src/core/Deal.Api/Middleware/SessionMiddleware.cs
index 833ec86..4f8440d 100644
--- a/src/core/Deal.Api/Middleware/SessionMiddleware.cs
+++ b/src/core/Deal.Api/Middleware/SessionMiddleware.cs
@@ -1,6 +1,10 @@
using Deal.Api.Configuration;
using Deal.Api.Http;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.Options;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
diff --git a/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs b/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs
index 0f58c7d..3c18481 100644
--- a/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs
+++ b/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs
@@ -1,147 +1,156 @@
-using Deal.Infrastructure.Persistence;
-using Deal.Modules.Kanban.Application;
-using Deal.Modules.Pipeline.Application;
-using Deal.Modules.Pipeline.Application.Models;
-using Deal.Modules.Tenants.Application;
-using Deal.Modules.Tenants.Application.Models;
-using Deal.SharedKernel.Tenants;
-using Microsoft.EntityFrameworkCore;
-
-namespace Deal.Api.Observability;
-
-///
-/// Сборщик глубин очередей и активных сессий (этап 12, §10.2): единый источник для метрик и health.
-///
-///
-///
-/// Обходит реестр тенантов (на тенант — вложенный scope с ITenantContext.SetTenant) и считает через
-/// существующие сервисы/порты, без дублирования SQL:
-///
-/// - глубина очереди пайплайна — (new+filtered);
-/// - глубина очереди обучения ML — (count(MlOutbox));
-/// - активные сессии — count(public.sessions) + count(public.operator_sessions) с непросроченным ExpiresAt.
-///
-/// Переиспользуется фоновым (публикация в DealMetrics) и операторским
-/// health (глубины прямо в JSON). Ошибки каждой секции логируются и не выбрасываются наружу (сбой тенанта не
-/// валит проход; наружу летит только отмена); значения агрегируются по всем тенантам.
-///
-///
-public sealed class RuntimeDepthsCollector
-{
- private readonly IServiceScopeFactory _scopeFactory;
- private readonly ILogger _logger;
-
- ///
- /// Создаёт сборщик глубин.
- ///
- /// Фабрика scope: проход и тенант — в собственных scope.
- /// Логгер ошибок секций.
- public RuntimeDepthsCollector(IServiceScopeFactory scopeFactory, ILogger logger)
- {
- ArgumentNullException.ThrowIfNull(scopeFactory);
- ArgumentNullException.ThrowIfNull(logger);
- _scopeFactory = scopeFactory;
- _logger = logger;
- }
-
- ///
- /// Собирает снимок глубин очередей и числа активных сессий (агрегат по всем тенантам).
- ///
- /// Токен отмены (пробрасывается в EF-запросы; отмена — единственное исключение наружу).
- /// Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий.
- public async Task CollectAsync(CancellationToken ct)
- {
- await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
- int sessions = await CountActiveSessionsAsync(cycleScope, ct);
- (long queue, long outbox) = await SumTenantDepthsAsync(cycleScope, ct);
- return new RuntimeDepthsDto(queue, outbox, sessions);
- }
-
- // Считает активные непросроченные сессии пользователей и операторов (public-схема).
- // cycleScope: Scope прохода (DealDbContext — без tenant-контекста).
- // ct: Токен отмены.
- // Возвращает: Число активных сессий; сбой секции — 0 (снимок остаётся полезным).
- private async Task CountActiveSessionsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
- {
- try
- {
- DealDbContext dbContext = cycleScope.ServiceProvider.GetRequiredService();
- DateTimeOffset now = DateTimeOffset.UtcNow;
- int tenantSessions = await dbContext.Sessions.CountAsync(session => session.ExpiresAt > now, ct);
- int operatorSessions = await dbContext.OperatorSessions.CountAsync(session => session.ExpiresAt > now, ct);
- return tenantSessions + operatorSessions;
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception exception)
- {
- _logger.LogWarning(exception, "Сборщик глубин: подсчёт активных сессий не удался");
- return 0;
- }
- }
-
- // Суммирует глубины очередей по всем тенантам реестра.
- // cycleScope: Scope прохода (реестр тенантов читается без tenant-контекста).
- // ct: Токен отмены.
- // Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox); недоступность реестра — (0, 0).
- private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
- {
- long queueDepth = 0;
- long outboxDepth = 0;
- try
- {
- ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService();
- IReadOnlyList tenants = await tenantRepository.ListAsync(ct);
- foreach (TenantRecordDto tenant in tenants)
- {
- (int queue, int outbox) = await CollectTenantAsync(tenant, ct);
- queueDepth += queue;
- outboxDepth += outbox;
- }
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception exception)
- {
- _logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался");
- }
-
- return (queueDepth, outboxDepth);
- }
-
- // Считает глубины очередей одного тенанта в собственном scope (SetTenant → сервисы → Reset).
- // tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>).
- // ct: Токен отмены прохода.
- // Возвращает: Пара (глубина очереди пайплайна, глубина MlOutbox) для тенанта; сбой — (0, 0).
- private async Task<(int Queue, int Outbox)> CollectTenantAsync(TenantRecordDto tenant, CancellationToken ct)
- {
- await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
- ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService();
- try
- {
- tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
- PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService();
- QueueCountsDto counts = await processing.QueueCountsAsync(ct);
- IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService();
- int outbox = await learningStore.CountOutboxAsync(ct);
- return (counts.Total, outbox);
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception exception)
- {
- _logger.LogWarning(exception, "Сборщик глубин: подсчёт очередей тенанта {TenantId} не удался", tenant.Id);
- return (0, 0);
- }
- finally
- {
- tenantContext.Reset();
- }
- }
-}
+using Deal.Infrastructure.Persistence;
+using Deal.Modules.Kanban.Application.Abstractions;
+using Deal.Modules.Kanban.Application.Extensions;
+using Deal.Modules.Kanban.Application.Models;
+using Deal.Modules.Kanban.Application.Registrars;
+using Deal.Modules.Kanban.Application.Services;
+using Deal.Modules.Pipeline.Application.Abstractions;
+using Deal.Modules.Pipeline.Application.Models;
+using Deal.Modules.Pipeline.Application.Registrars;
+using Deal.Modules.Pipeline.Application.Services;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
+using Deal.SharedKernel.Tenants;
+using Microsoft.EntityFrameworkCore;
+
+namespace Deal.Api.Observability;
+
+///
+/// Сборщик глубин очередей и активных сессий (этап 12, §10.2): единый источник для метрик и health.
+///
+///
+///
+/// Обходит реестр тенантов (на тенант — вложенный scope с ITenantContext.SetTenant) и считает через
+/// существующие сервисы/порты, без дублирования SQL:
+///
+/// - глубина очереди пайплайна — (new+filtered);
+/// - глубина очереди обучения ML — (count(MlOutbox));
+/// - активные сессии — count(public.sessions) + count(public.operator_sessions) с непросроченным ExpiresAt.
+///
+/// Переиспользуется фоновым (публикация в DealMetrics) и операторским
+/// health (глубины прямо в JSON). Ошибки каждой секции логируются и не выбрасываются наружу (сбой тенанта не
+/// валит проход; наружу летит только отмена); значения агрегируются по всем тенантам.
+///
+///
+public sealed class RuntimeDepthsCollector
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly ILogger _logger;
+
+ ///
+ /// Создаёт сборщик глубин.
+ ///
+ /// Фабрика scope: проход и тенант — в собственных scope.
+ /// Логгер ошибок секций.
+ public RuntimeDepthsCollector(IServiceScopeFactory scopeFactory, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(scopeFactory);
+ ArgumentNullException.ThrowIfNull(logger);
+ _scopeFactory = scopeFactory;
+ _logger = logger;
+ }
+
+ ///
+ /// Собирает снимок глубин очередей и числа активных сессий (агрегат по всем тенантам).
+ ///
+ /// Токен отмены (пробрасывается в EF-запросы; отмена — единственное исключение наружу).
+ /// Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий.
+ public async Task CollectAsync(CancellationToken ct)
+ {
+ await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
+ int sessions = await CountActiveSessionsAsync(cycleScope, ct);
+ (long queue, long outbox) = await SumTenantDepthsAsync(cycleScope, ct);
+ return new RuntimeDepthsDto(queue, outbox, sessions);
+ }
+
+ // Считает активные непросроченные сессии пользователей и операторов (public-схема).
+ // cycleScope: Scope прохода (DealDbContext — без tenant-контекста).
+ // ct: Токен отмены.
+ // Возвращает: Число активных сессий; сбой секции — 0 (снимок остаётся полезным).
+ private async Task CountActiveSessionsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
+ {
+ try
+ {
+ DealDbContext dbContext = cycleScope.ServiceProvider.GetRequiredService();
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ int tenantSessions = await dbContext.Sessions.CountAsync(session => session.ExpiresAt > now, ct);
+ int operatorSessions = await dbContext.OperatorSessions.CountAsync(session => session.ExpiresAt > now, ct);
+ return tenantSessions + operatorSessions;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ _logger.LogWarning(exception, "Сборщик глубин: подсчёт активных сессий не удался");
+ return 0;
+ }
+ }
+
+ // Суммирует глубины очередей по всем тенантам реестра.
+ // cycleScope: Scope прохода (реестр тенантов читается без tenant-контекста).
+ // ct: Токен отмены.
+ // Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox); недоступность реестра — (0, 0).
+ private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
+ {
+ long queueDepth = 0;
+ long outboxDepth = 0;
+ try
+ {
+ ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService();
+ IReadOnlyList tenants = await tenantRepository.ListAsync(ct);
+ foreach (TenantRecordDto tenant in tenants)
+ {
+ (int queue, int outbox) = await CollectTenantAsync(tenant, ct);
+ queueDepth += queue;
+ outboxDepth += outbox;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ _logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался");
+ }
+
+ return (queueDepth, outboxDepth);
+ }
+
+ // Считает глубины очередей одного тенанта в собственном scope (SetTenant → сервисы → Reset).
+ // tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>).
+ // ct: Токен отмены прохода.
+ // Возвращает: Пара (глубина очереди пайплайна, глубина MlOutbox) для тенанта; сбой — (0, 0).
+ private async Task<(int Queue, int Outbox)> CollectTenantAsync(TenantRecordDto tenant, CancellationToken ct)
+ {
+ await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
+ ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService();
+ try
+ {
+ tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
+ PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService();
+ QueueCountsDto counts = await processing.QueueCountsAsync(ct);
+ IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService();
+ int outbox = await learningStore.CountOutboxAsync(ct);
+ return (counts.Total, outbox);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ _logger.LogWarning(exception, "Сборщик глубин: подсчёт очередей тенанта {TenantId} не удался", tenant.Id);
+ return (0, 0);
+ }
+ finally
+ {
+ tenantContext.Reset();
+ }
+ }
+}
diff --git a/src/core/Deal.Api/PipelineWorkerScheduler.cs b/src/core/Deal.Api/PipelineWorkerScheduler.cs
index f1f43fb..fc688c2 100644
--- a/src/core/Deal.Api/PipelineWorkerScheduler.cs
+++ b/src/core/Deal.Api/PipelineWorkerScheduler.cs
@@ -1,9 +1,14 @@
using Deal.Api.Events;
using Deal.Modules.Kanban.Application.Models;
-using Deal.Modules.Pipeline.Application;
+using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Pipeline.Application.Registrars;
+using Deal.Modules.Pipeline.Application.Services;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants;
namespace Deal.Api;
diff --git a/src/core/Deal.Api/Program.cs b/src/core/Deal.Api/Program.cs
index 524a112..8816afd 100644
--- a/src/core/Deal.Api/Program.cs
+++ b/src/core/Deal.Api/Program.cs
@@ -1,742 +1,760 @@
-using System.Net;
-using System.Net.Sockets;
-using System.Text.Encodings.Web;
-using Deal.Api;
-using Deal.Api.Configuration;
-using Deal.Api.Endpoints;
-using Deal.Api.Events;
-using Deal.Api.Hosting;
-using Deal.Api.Http;
-using Deal.Api.Logging;
-using Deal.Api.Middleware;
-using Deal.Api.Observability;
-using Deal.Api.Telegram;
-using Deal.Contracts.Integrations;
-using Deal.Infrastructure;
-using Deal.Infrastructure.Data;
-using Deal.Infrastructure.Integrations;
-using Deal.Infrastructure.Integrations.Storage;
-using Deal.Infrastructure.Persistence;
-using Deal.Infrastructure.Services;
-using Deal.Modules.Discovery.Application;
-using Deal.Modules.Kanban.Application;
-using Deal.Modules.Pipeline.Application;
-using Deal.Modules.Settings.Application;
-using Deal.Modules.Telegram.Application;
-using Deal.Modules.Tenants.Application;
-using Deal.Modules.Tenants.Application.Models;
-using Deal.SharedKernel.Tenants;
-using Microsoft.AspNetCore.HttpOverrides;
-using Microsoft.AspNetCore.Server.Kestrel.Core;
-using Microsoft.AspNetCore.Server.Kestrel.Https;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Diagnostics.HealthChecks;
-// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
-using CookieOptions = Deal.Api.Configuration.CookieOptions;
-
-const string cookiesSectionName = "Cookies";
-// Секция настроек куки оператора (Ruling 1): имя deal_operator_session, срок 12 ч, Secure из конфига.
-const string operatorCookiesSectionName = "OperatorCookies";
-// Имя CORS-политики (план Task 12, Ruling 10): одна политика, режим зависит от Security:AllowedOrigins.
-const string corsPolicyName = "cors";
-// Секции конфигурации интеграций (Ruling 6): Services:Ml / Services:Ai / Services:Telegram → {UseLocal, Endpoint}.
-const string servicesSectionName = "Services:Ml";
-const string aiServicesSectionName = "Services:Ai";
-const string telegramServicesSectionName = "Services:Telegram";
-// Имя истории tenant-миграций без схемы (схема — через search_path; миграции применяет
-// TenantProvisioningService на старте, runtime-контекст их не выполняет).
-const string tenantMigrationsHistoryTable = "__TenantMigrationsHistory";
-// Порт gRPC-ингресса по умолчанию (Ruling 7): :5082; переопределяется env GRPC_INGRESS_PORT.
-const int defaultIngressPort = 5082;
-const string ingressPortEnvKey = "GRPC_INGRESS_PORT";
-// Ключ конфигурации адресов основного HTTP-эндпоинта (--urls/ASPNETCORE_URLS/launchSettings).
-const string serverUrlsKey = "urls";
-// Фолбэк основного HTTP-адреса при отсутствии явных URL (дефолт ASP.NET Core http://localhost:5000).
-const string defaultHttpUrl = "http://localhost:5000";
-// Ключ env-переопределения дефолт-бюджета нового тенанта (Ruling 3; токенов в месяц, период — всегда month).
-const string defaultAiBudgetEnvKey = "DEAL_DEFAULT_AI_BUDGET";
-// Секция настроек rate limiting (план Task 11, Ruling 5): RateLimit:Enabled=false в dev/тестах по
-// умолчанию (curl-приёмки не режутся); PROD включает env-переопределением RateLimit__Enabled=true
-// (compose-prod, Task 14).
-const string rateLimitSectionName = "RateLimit";
-// Секция авто-очистки данных (этап 12, пакет B): DataRetention:AuditRetentionDays (дефолт 180) + Enabled —
-// фоновый цикл DataRetentionScheduler чистит audit_log по retention, лимиты/счётчики прошедших окон.
-const string dataRetentionSectionName = "DataRetention";
-// Секция настроек безопасности HTTP (план Task 12, Ruling 10): Security:AllowedOrigins — явный allowlist
-// Origin-проверки/CORS (пусто — dev-режим «свой origin» + CORS-любой; PROD — домены фронта, Ruling 9).
-const string securitySectionName = "Security";
-// Секция доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11): ForwardedHeaders:KnownProxies/
-// KnownNetworks — доверенные прокси (Caddy в PROD); dev-дефолт в appsettings — loopback.
-const string forwardedHeadersSectionName = "ForwardedHeaders";
-
-// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-core-<дата>.json.
-const string coreProcessName = "core";
-
-var builder = WebApplication.CreateBuilder(args);
-
-// Структурированные логи Serilog (Ruling 7, план Task 14): консоль JSON в prod / текст в dev +
-// rolling-файл data/logs/deal-core-*.json (data — volume контейнера). Уровень/каталог — env
-// DEAL_LOG_LEVEL/DEAL_LOGS_DIR (см. DealLogging). Регистрируется до остальных сервисов: логирование
-// заменяет провайдеры Microsoft при builder.Build().
-DealLogging.Configure(builder, coreProcessName);
-
-// Метрики OpenTelemetry → Prometheus (этап 12, пакет A): эндпоинт /metrics в формате Prometheus на
-// отдельном HTTP/1.1 Kestrel-эндпоинте (порт 9464/env METRICS_PORT) + инструментация входящих HTTP-
-// запросов и исходящих HTTP-клиентов; прикладные метрики — SharedKernel/Observability/DealMetrics.
-int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
-DealMetricsHosting.AddDealMetrics(builder, metricsPort);
-
-// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует
-// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД.
-var connectionString = builder.Configuration.GetConnectionString("DealPostgres")
- ?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
-builder.Services.AddDbContext(options => options.UseNpgsql(connectionString));
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-
-// mTLS внутреннего gRPC-транспорта (Ruling 6, план Task 13): env DEAL_MTLS_* — флаг и пути/пароли
-// сертификатов (только env, Ruling 13). Dev-дефолт — выключено: сервисы и ингресс остаются на plaintext +
-// service-token (Ruling 2 этапа 6); compose-prod (Task 14) передаёт env и монтирует deploy/certs
-// (генерация — scripts/mtls-certs.sh). При DEAL_MTLS_ENABLED=1 сертификаты грузятся сразу (fail-fast на
-// битые пути/пароли) — один экземпляр используют и Kestrel-ингресс ниже, и gRPC-клиенты
-// (Ml/Ai/Telegram-каналы + ServiceHealthProbe).
-MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(builder.Configuration);
-MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(mtlsOptions);
-if (mtlsCertificates is not null)
-{
- builder.Services.AddSingleton(mtlsCertificates);
-}
-
-// Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (как раньше — --urls/ASPNETCORE_URLS/
-// launchSettings) + второй endpoint gRPC-ингресса telegram-service (:5082, HTTP/2, env GRPC_INGRESS_PORT;
-// план Task 12, Ruling 7). Явные Listen заменяют URL-биндинг Kestrel, поэтому основной эндпоинт
-// пере-биндим адресами конфигурации "urls" явно (см. BindMainHttpEndpoints ниже). Ingress слушает все
-// интерфейсы (AnyIP): в dev к нему ходит telegram-service из compose-сети через host.docker.internal (Ruling 12).
-builder.WebHost.ConfigureKestrel(kestrel =>
-{
- BindMainHttpEndpoints(kestrel, builder.Configuration[serverUrlsKey]);
- int ingressPort = ParsePort(builder.Configuration[ingressPortEnvKey]) ?? defaultIngressPort;
- kestrel.ListenAnyIP(ingressPort, listen =>
- {
- listen.Protocols = HttpProtocols.Http2;
- if (mtlsCertificates is not null)
- {
- // mTLS-ингресс (Ruling 6, Task 13): HTTPS с серверным сертификатом core + обязательный клиентский
- // сертификат (цепочка до CA из DEAL_MTLS_CA_PEM). Основной HTTP :5080 остаётся http — TLS наружу
- // терминирует Caddy (Ruling 9, compose-prod Task 14).
- listen.UseHttps(https =>
- {
- https.ServerCertificate = mtlsCertificates.ServerCertificate;
- https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
- https.ClientCertificateValidation = mtlsCertificates.ValidateClientCertificate;
- });
- }
- });
-});
-
-// TenantDbContext — scoped-контекст бессхемной модели тенанта (таблица settings и др.): строка
-// подключения на каждый scope строится по текущему ITenantContext (заполняет SessionMiddleware)
-// с Search Path на схему тенанта (ConnectionStringProvider.ForTenant). Опции живут в scope запроса
-// (optionsLifetime: Scoped) — иначе опции с первым тенантом закешировались бы в singleton.
-// Безопасность: вне tenant-запроса (нет сессии) контекст не имеет смысла — ошибка конфигурации.
-builder.Services.AddDbContext(
- (serviceProvider, options) =>
- {
- var tenantContext = serviceProvider.GetRequiredService();
- if (!tenantContext.HasTenant)
- {
- throw new InvalidOperationException(
- "TenantDbContext запрошен вне tenant-запроса: на запрос не разрешена сессия "
- + "(ITenantContext.HasTenant == false).");
- }
-
- var connectionStringProvider = serviceProvider.GetRequiredService();
- options.UseNpgsql(
- connectionStringProvider.ForTenant(tenantContext.TenantId),
- npgsql => npgsql.MigrationsHistoryTable(tenantMigrationsHistoryTable));
- },
- contextLifetime: ServiceLifetime.Scoped,
- optionsLifetime: ServiceLifetime.Scoped);
-
-// Модуль Tenants и его EF-адаптеры («port & adapter», Ruling 1).
-builder.Services.AddTenantsModule();
-
-// Лимиты ИИ-бюджета (Ruling 3, Task 8): дефолт-бюджет лениво создаваемой строки public.tenant_limits —
-// env DEAL_DEFAULT_AI_BUDGET (токенов в месяц) с фолбэком на константу модуля TokenBudgetDefaults (10 000 000);
-// период нового тенанта — month (константа). Значение читается один раз на старте и передаётся адаптеру
-// TenantLimitStore (GetOrCreateAsync при первом чтении/списании, задачи 7/10 list-путь тоже закрыт).
-TokenLimitDefaults tenantLimitDefaults = new(
- ResolveDefaultAiBudget(builder.Configuration), TokenBudgetDefaults.DefaultPeriod);
-builder.Services.AddDealPersistence(tenantLimitDefaults);
-
-// Шифрование секретов (Ruling 2): ISecretCipher — AES-256-GCM; ключ из DEAL_ENCRYPTION_KEY
-// либо файла data/encryption.key под ContentRoot (dev). Ключ разрешается на старте —
-// невалидный env-ключ останавливает запуск.
-builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
-
-// Внешние интеграции (Tasks 9/16/15, Rulings 4/5/6/9): IMlClient — детерминированная заглушка LocalMlClient
-// (Services:Ml:UseLocal=true, default; обучение — этап 3) либо gRPC-клиент GrpcMlClient (UseLocal=false,
-// ml-service :5103). Local-адаптеры читают KV-настройки тенанта через ISettingsStore — scoped (вне
-// tenant-запроса не разрешимы). IColumnSuggester — LocalColumnSuggester (эвристика, Self-Review L525–527).
-MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get() ?? new MlServiceOptions();
-builder.Services.AddSingleton(mlOptions);
-// AI-интеграция (план Task 15, Ruling 6/9): IAiClassifier/IAiTools — Local-адаптеры (Services:Ai:UseLocal=true,
-// default; локальный разбор ядра / инструменты не поддерживаются) либо декораторы бюджетного гейта поверх
-// gRPC-клиентов ai-service (UseLocal=false, ai-service :5102; AddDealIntegrations регистрирует GrpcAiClassifier/
-// GrpcAiTools + транспорт AiGrpcConnection — fail-fast, как MlGrpcConnection — и оборачивает их в
-// BudgetedAiClassifier/BudgetedAiTools, Ruling 3/Task 9).
-AiServiceOptions aiOptions = builder.Configuration.GetSection(aiServicesSectionName).Get() ?? new AiServiceOptions();
-builder.Services.AddSingleton(aiOptions);
-// Telegram-гейт (план Task 14, Ruling 6/7): LocalTelegramGateway (Services:Telegram:UseLocal=true, default —
-// нейтральный no-op/idle) либо gRPC-клиент GrpcTelegramClient (UseLocal=false, telegram-service :5101).
-TelegramServiceOptions telegramOptions = builder.Configuration.GetSection(telegramServicesSectionName).Get() ?? new TelegramServiceOptions();
-builder.Services.AddSingleton(telegramOptions);
-builder.Services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions, mtlsCertificates);
-
-// Health-проба автономных сервисов для операторского health (план Task 10, Ruling 3/6/9): grpc.health.v1
-// к Services:*:Endpoint с дедлайном 3 с (ServiceHealthProbe). Stateless, singleton — пробы строят
-// короткоживущие каналы на каждый вызов (mTLS-каналы — при включённом флаге, Task 13/Ruling 6).
-builder.Services.AddSingleton(new ServiceHealthProbe(mtlsCertificates));
-
-// Файловое хранилище вложений проектных карточек (Ruling 4, Task 6): LocalFileStorage (data/attachments
-// под ContentRoot) — dev/curl/unit по умолчанию; MinioFileStorage регистрируется, только когда сконфигурирован
-// MinIO (секция Storage:Minio либо env-алиасы DEAL_MINIO_*; compose-сервис deal-minio, порты 9000/9001).
-// Singleton: хранилище не привязано к схеме тенанта (объекты — в едином бакете/каталоге, мульти-аренда
-// объектного хранилища — этап 7 SaaS). Режим логируется на старте (см. ниже) — приёмка Task 6.
-builder.Services.AddDealFileStorage(builder.Configuration, builder.Environment.ContentRootPath);
-
-// Модуль Settings (сервис настроек тенанта); адаптеры ISettingsStore/ISecretCipher уже
-// зарегистрированы AddDealPersistence/AddDealSecurity выше (см. Task 4).
-builder.Services.AddSettingsModule();
-
-// Модуль Kanban — единый домен карточки (Ruling 12): регистратор сервисов карточек/контейнеров/тиков;
-// порт-адаптер ICardStore → KanbanStore уже зарегистрирован AddDealPersistence.
-builder.Services.AddKanbanModule();
-
-// Модуль Pipeline (Ruling 10, Task 9): приём/обработка/воркер и ядра разбора этапа 4. Порт-адаптер
-// IPipelineStore → PipelineStore и внешние порты (IMlClient/IAiClassifier) уже зарегистрированы
-// AddDealPersistence/AddDealIntegrations выше; сервисы модуля вызывают из эндпоинтов /api/pipeline/*
-// и gRPC-ингресса telegram-service, pump — admin/tick (Task 10) и фоновый цикл (Task 11).
-builder.Services.AddPipelineModule();
-
-// Модуль Telegram (план Task 13, Ruling 7): сервис каталога диалогов (DialogsService) — владелец таблиц
-// Dialogs/TgMessages схемы тенанта (миграция TenantTelegram). Порт-адаптеры ITelegramStore → TelegramStore и
-// ITelegramGateway → LocalTelegramGateway/GrpcTelegramClient зарегистрированы AddDealPersistence/AddDealIntegrations
-// выше; сервис зовут gRPC-ингресс (SyncDialogs/PushMessage) и эндпоинты /api/tg (Task 14).
-builder.Services.AddTelegramModule();
-
-// Модуль Discovery (план Task 17/18, Ruling 9/10): сервисы задач/кандидатов/чёрного списка/лога, план-бюджет
-// и воркер (оценка/бан-гард/паузы). Порт-адаптер IDiscoveryStore → DiscoveryStore зарегистрирован
-// AddDealPersistence; внешние порты (ITelegramGateway/IAiTools/IMlClient) — AddDealIntegrations выше. Эндпоинты
-// /api/discovery добавляет Task 19; фоновый цикл воркера — DiscoveryWorkerScheduler ниже.
-builder.Services.AddDiscoveryModule();
-
-// Статус/ключи вкладки Telegram (план Task 14, Ruling 8): сборка GET /api/tg/status (гейт + KV tgAccount +
-// счётчик мониторящихся + keysSet) и чтение глобальных ключей приложения (telegramKeys в public.global_settings,
-// расшифровка apiHash — задаёт оператор, ТЗ §4.1/§8.1). Scoped: зависимости — ISettingsStore/ITelegramStore
-// на TenantDbContext схемы тенанта запроса, IGlobalSettingsStore — на системном DealDbContext.
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-
-// Фоновые спуски перечитывания каналов (план Task 14, Ruling 8; аналог python-_spawn роутеров): эндпоинты
-// мониторинга/«Перечитать» отвечают сразу, тяжёлый разбор идёт в отдельном scope с захваченным tenant-контекстом.
-builder.Services.AddSingleton();
-
-// Оркестратор ручного тика (план Task 10, Rulings 8/9): тик правил хранения (Kanban) + очистка отсева
-// Pipeline + один проход pump + SSE-публикации (тосты/new_card) для POST /api/admin/tick (StorageEndpoints).
-// Scoped: зависимости живут в рамках tenant-запроса (scoped-сервисы модулей на TenantDbContext схемы).
-builder.Services.AddScoped();
-
-// Обслуживание FTS-индексов схемы тенанта (Ruling 6, план Task 10): POST /api/admin/fts/rebuild —
-// CREATE INDEX IF NOT EXISTS + ANALYZE (FtsMaintenance) на TenantDbContext запроса (scoped, как адаптеры).
-builder.Services.AddScoped();
-
-// SSE-брокер этапа (Ruling 5): singleton per-tenant каналов событий; подписка — GET /api/events,
-// публикации — из эндпоинтов Api после вызова сервисов (Tasks 10/13/14).
-builder.Services.AddSingleton();
-
-// SSE-тосты статистики тика правил хранения (Ruling 8): единый хелпер для POST /api/admin/tick
-// (StorageEndpoints) и фонового StorageTickScheduler (Task 11) — без дублирования текстов/иконок.
-builder.Services.AddSingleton();
-
-// Общий воркер-гейт pump тенанта (план Task 11, Ruling 8; аналог asyncio.Lock pipeline.py L38–42):
-// POST /api/admin/tick (AdminTickOrchestrator) и фоновый цикл разбора очереди (PipelineWorkerScheduler)
-// не разбирают очередь одного тенанта одновременно (singleton per-tenant флагов, Interlocked).
-builder.Services.AddSingleton();
-
-// Rate limiting и защита входа (план Task 11, Ruling 5; этап 12, пакет B — хранилище на Postgres): секция
-// "RateLimit" (appsettings.json + env RateLimit__*). Enabled=false в dev/тестах — политики/middleware/
-// интерцептор не регистрируются вовсе (Ruling 5: «в dev выключено — curl-приёмки не режутся»);
-// LoginAttemptGuard (окно ip|login 5 неудач/15 мин в public.rate_limit_counters) регистрируется всегда,
-// но активен только при Enabled.
-RateLimitOptions rateLimitOptions = builder.Configuration
- .GetSection(rateLimitSectionName)
- .Get() ?? new RateLimitOptions();
-builder.Services.AddSingleton(rateLimitOptions);
-// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер
-// (public.rate_limit_counters). Активен только при Enabled (no-op иначе).
-builder.Services.AddScoped();
-if (rateLimitOptions.Enabled)
-{
- builder.Services.AddDealRateLimiter(rateLimitOptions);
-}
-
-// gRPC-ингресс telegram-service (план Task 12, Ruling 1/7): сервер Deal.Grpc.Telegram.IngressService
-// на отдельном Kestrel-endpoint (:5082, HTTP/2, см. ConfigureKestrel выше) в том же процессе. Token
-// из metadata «service-token» проверяет интерцептор (fail-closed, DEAL_SERVICE_TOKEN); AddAuthentication
-// не нужен — пользовательская сессия HTTP ингрессом не используется (tenant-id из metadata → SetTenant).
-builder.Services.AddGrpc(grpc =>
-{
- // Access-лог RPC ингресса (Ruling 7, Task 14): ПЕРВЫМ в цепочке — логируются и отклонённые
- // вызовы (401/429); gRPC-health не логируется (см. RpcCallLoggingInterceptor).
- grpc.Interceptors.Add();
- grpc.Interceptors.Add();
- if (rateLimitOptions.Enabled)
- {
- // Лимит входящего потока по tenant-id (план Task 11, Ruling 5): окно считает общий
- // singleton-лимитер (CreateLimiter) — экземпляры интерцептора общий PartitionedRateLimiter
- // разделяют; health-методы освобождены (см. IngressRateLimitInterceptor).
- grpc.Interceptors.Add();
- }
-});
-if (rateLimitOptions.Enabled)
-{
- builder.Services.AddSingleton(provider =>
- IngressRateLimitInterceptor.CreateLimiter(
- provider.GetRequiredService(),
- rateLimitOptions.GrpcIngressPerMinute));
-}
-
-builder.Services.AddScoped();
-
-// gRPC-health ингресса (план Task 20, Ruling 12): healthcheck контейнера core в docker compose.
-// grpc.health.v1.Health интерцептор токеном не проверяет (инфраструктурный liveness, как в сервисах
-// этапа T2–T4); регистрируется явная проверка "ready" — без неё health-сервис отвечает UNKNOWN.
-// Живучесть интеграций (ml/ai/telegram) health не проверяет — недоступность сервиса это UNAVAILABLE
-// на RPC и фолбэк Local-адаптеров, а не падение хоста (Ruling 6).
-builder.Services
- .AddGrpcHealthChecks()
- .AddCheck("ready", () => HealthCheckResult.Healthy("хост Deal.Api готов"));
-
-// Проверка подключения AI-провайдера (Task 6, Ruling 7): порт модуля IAiConnectionChecker →
-// HTTP-адаптер Infrastructure с собственным HttpClient (фабрика AddHttpClient, таймаут 12 с).
-// HTTP наружу ходит только по действию Settings-экрана (POST /api/ai/check) — GET {base}/models.
-builder.Services.AddHttpClient(
- client => client.Timeout = TimeSpan.FromSeconds(AiConnectionChecker.RequestTimeoutSeconds));
-
-// Курсы валют (Task 8, Ruling 6): порт модуля IRatesSource → HTTP-адаптер ЦБ с собственным
-// HttpClient (таймаут 15 с, как httpx timeout=15 в rates.py). URL — фиксированная константа
-// адаптера (SSRF-allowlist), источник тенантом не настраивается. Типизированный клиент
-// регистрируется transient и живёт в рамках scope запроса (как IAiConnectionChecker, Task 6).
-builder.Services.AddHttpClient(
- client => client.Timeout = TimeSpan.FromSeconds(CbrRateSource.RequestTimeoutSeconds));
-
-// Фоновое обновление кэша курсов вне запроса (Ruling 6): PATCH rateSource и лениво на GET —
-// собственный scope + in-flight guard (см. RatesRefreshScheduler).
-builder.Services.AddSingleton();
-
-// Bootstrap при старте (Ruling 8): дефолтный тенант + admin, провижининг схем всех тенантов.
-builder.Services.AddHostedService();
-
-// Bootstrap оператора при старте (Ruling 1 этапа 7): env DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD,
-// dev-дефолт operator/operator в Development; в Production без env — warning и пропуск. Идёт после
-// TenantBootstrapService: операторские public-таблицы не зависят от провижининга схем тенантов.
-builder.Services.AddHostedService();
-
-// Фоновый цикл правил хранения (план Task 11, Ruling 8; аналог _storage_loop main.py L43–53): каждые
-// 30 с тикает ВСЕ тенанты (StorageTickService + автоочистка отсева пайплайна 3 суток) и публикует
-// SSE-тосты. Регистрируется после Bootstrap — первый проход стартует уже после провижининга схем.
-builder.Services.AddHostedService();
-
-// Фоновый цикл SSE-алертов ИИ-бюджета (план Task 9, Ruling 3; эталон StorageTickScheduler): каждые 60 с
-// проверяет ВСЕ тенанты и публикует в канал тенанта тост при пересечении порогов 80/100% (TryMark*-CAS —
-// один тост на порог за период). Идёт после Bootstrap: реестр тенантов провижинен до первого прохода.
-builder.Services.AddHostedService();
-
-// Фоновый цикл разбора очереди входящих (план Task 11, Ruling 8/11; аналог _pipeline_loop main.py L79–88):
-// каждые 2 с pump'ит ВСЕ тенанты (PipelineWorkerService.PumpOnceAsync под общим PipelinePumpGate) и
-// публикует SSE new_card по созданным карточкам — ingest разбирается без ручного tick (приёмка Task 11).
-// После StorageTickScheduler: очередь цикла — 2 с, первый проход сразу после старта.
-builder.Services.AddHostedService();
-
-// Фоновый флашер очереди обучения ML (план Task 16, Ruling 6; аналог _ml_sync_loop main.py): каждые 10 с
-// выгружает MlOutbox тенантов в ml-service (TrainBatch, порции по 10, ≤100/цикл; удаление после успеха).
-// Регистрируется только в gRPC-режиме (UseLocal=false) — Local-режиму (этапы 2–5) сервис не нужен, очередь
-// копится до подключения ml-service (python L56–82); MlGrpcConnection создан в AddDealIntegrations (fail-fast).
-if (!mlOptions.UseLocal)
-{
- builder.Services.AddHostedService();
-}
-
-// Фоновый цикл Discovery-воркера (план Task 18, Ruling 10; аналог _discovery_loop main.py): каждые 5 с
-// делает ОДИН шаг (поиск/оценка/авто-вступление/done) для каждой running-задачи всех тенантов. Работает
-// всегда: в Local-режиме гейт нейтрален (поиск пуст/история недоступна), реальные действия — при
-// подключённом telegram-service (UseLocal=false). После Bootstrap: первый проход стартует после провижининга.
-builder.Services.AddHostedService();
-
-// Сборщик глубин очередей/сессий (этап 12, §10.2): общий источник для метрик и операторского health.
-builder.Services.AddSingleton();
-
-// Фоновый сборщик gauge-метрик (этап 12, пакет A): каждые 15 с публикует глубины очередей (пайплайн,
-// MlOutbox) по всем тенантам и число активных сессий в meter Deal (callback /metrics отдаёт их Prometheus).
-// Регистрируется последним из фоновых: после Bootstrap (реестр тенантов провижинен до первого прохода).
-builder.Services.AddHostedService();
-
-// Фоновый цикл авто-очистки данных (этап 12, пакет B; эталон DealMetricsCollector): раз в сутки удаляет
-// записи audit_log старше DataRetention:AuditRetentionDays (дефолт 180 дней), сбрасывает накопительные
-// поля лимитов прошедших периодов и убирает завершившиеся окна распределённых счётчиков. Регистрируется
-// последним из фоновых: после Bootstrap (реестр тенантов провижинен до первого прохода).
-DataRetentionOptions dataRetentionOptions = builder.Configuration
- .GetSection(dataRetentionSectionName)
- .Get() ?? new DataRetentionOptions();
-builder.Services.AddSingleton(dataRetentionOptions);
-builder.Services.AddHostedService();
-
-// Кука сессии: имя/срок/Secure из секции "Cookies" (appsettings.json + env Cookies__*).
-builder.Services.Configure(builder.Configuration.GetSection(cookiesSectionName));
-
-// Кука операторской сессии (Ruling 1 этапа 7): имя deal_operator_session/срок/Secure из секции
-// "OperatorCookies" (appsettings.json + env OperatorCookies__*) — отдельная от тенантной deal_session.
-builder.Services.Configure(builder.Configuration.GetSection(operatorCookiesSectionName));
-
-// Ответы JSON — как в прототипе FastAPI: без \u-экранирования не-ASCII символов.
-builder.Services.ConfigureHttpJsonOptions(options =>
- options.SerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping);
-
-// Безопасность HTTP (план Task 12, Ruling 10(2)/9): Security:AllowedOrigins — явный allowlist Origin-
-// проверки мутаций и CORS (пусто — dev-режим «свой origin», см. AddCors ниже; PROD — домен фронта в
-// compose-prod). Инстанс регистрируется в DI: значение читается один раз на старте (политики формируются
-// при старте хоста), OriginGuardMiddleware получает его конструктором.
-SecurityOptions securityOptions = builder.Configuration
- .GetSection(securitySectionName)
- .Get() ?? new SecurityOptions();
-builder.Services.AddSingleton(securityOptions);
-
-// Доверие прокси-заголовкам (план Task 12; замечание ревью T4/T11): ForwardedHeaders — UseForwardedHeaders
-// включается env-переопределением (ForwardedHeaders__Enabled=true в PROD за Caddy, compose-prod Task 14);
-// dev-дефолт — false (прокси в dev-стеке нет, compose.dev публикует core напрямую).
-ForwardedHeadersConfig forwardedHeadersConfig = builder.Configuration
- .GetSection(forwardedHeadersSectionName)
- .Get() ?? new ForwardedHeadersConfig();
-builder.Services.AddSingleton(forwardedHeadersConfig);
-
-// CORS (план Task 12, Ruling 10(2)/9): пустой Security:AllowedOrigins — dev-режим «как в прототипе»
-// (любой origin/method/header, credentials=true; AllowAnyOrigin + AllowCredentials несовместимы — любой
-// origin разрешается предикатом). Непустой список (PROD, Ruling 9) — строгий allowlist + credentials.
-// Security-заголовки ответов (nosniff/X-Frame-Options/Referrer-Policy; CSP/HSTS) — на edge (Caddyfile,
-// Task 14): наружу статику и /api отдаёт Caddy, core отвечает JSON — на core не дублируются
-// (пересмотр Ruling 10(3), см. task-12-report).
-builder.Services.AddCors(options =>
- options.AddPolicy(corsPolicyName, cors =>
- {
- cors.AllowAnyHeader()
- .AllowAnyMethod()
- .AllowCredentials();
- if (securityOptions.AllowedOrigins.Length == 0)
- {
- cors.SetIsOriginAllowed(_ => true);
- }
- else
- {
- cors.WithOrigins(securityOptions.AllowedOrigins);
- }
- }));
-
-var app = builder.Build();
-
-// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A).
-DealMetricsHosting.MapDealMetrics(app);
-
-// Fail-closed для Production (Security review): дефолты кода рассчитаны на dev/тесты (rate limit выключен,
-// CORS — «любой origin»). Прод-окружение обязано задать защиту ЯВНО — иначе старт отказывается, а не
-// молча работает без лимитов/с открытым CORS.
-if (app.Environment.IsProduction())
-{
- if (!rateLimitOptions.Enabled)
- {
- throw new InvalidOperationException(
- "Production требует RateLimit__Enabled=true (анти-брутфорс и лимиты выключены код-дефолтом).");
- }
-
- if (securityOptions.AllowedOrigins.Length == 0)
- {
- throw new InvalidOperationException(
- "Production требует непустой Security__AllowedOrigins (CORS fail-open при пустом списке).");
- }
-}
-
-// Стартовый лог выбранного режима файлового хранилища (приёмка Task 6: запуск Api — LocalFileStorage
-// с путём data/attachments; при сконфигурированном MinIO — MinioFileStorage с endpoint/бакетом).
-app.Logger.LogInformation("Файловое хранилище: {FileStorage}", app.Services.GetRequiredService());
-
-// Стартовый лог режима ML-интеграции (приёмка Task 16): Local-заглушка либо gRPC-клиент ml-service.
-app.Logger.LogInformation(
- "ML-интеграция: {Mode} ({Endpoint})",
- mlOptions.UseLocal ? "Local-заглушка (MlOutbox накапливается)" : "gRPC-клиент ml-service",
- mlOptions.Endpoint);
-
-// Стартовый лог режима AI-интеграции (приёмка Task 15): локальный разбор ядра либо gRPC-клиент ai-service
-// под декоратором бюджетного гейта (Task 9: исчерпано/приостановлено → локальный разбор, приём не блокируется).
-app.Logger.LogInformation(
- "AI-интеграция: {Mode} ({Endpoint})",
- aiOptions.UseLocal ? "Local-адаптеры (разбор ядра/инструменты выключены)" : "gRPC-клиент ai-service",
- aiOptions.Endpoint);
-
-// Стартовый лог режима Telegram-гейта (приёмка Task 14): Local-заглушка либо gRPC-клиент telegram-service.
-app.Logger.LogInformation(
- "Telegram-гейт: {Mode} ({Endpoint})",
- telegramOptions.UseLocal ? "Local-заглушка (idle/не подключён)" : "gRPC-клиент telegram-service",
- telegramOptions.Endpoint);
-
-// Стартовый лог транспорта внутреннего gRPC (приёмка Task 13, Ruling 6): dev — plaintext + service-token,
-// PROD (DEAL_MTLS_ENABLED=1) — mTLS. Пути/пароли не логируются (Ruling 13).
-app.Logger.LogInformation(
- "Транспорт внутреннего gRPC: {Transport}",
- mtlsOptions.Enabled ? "mTLS (DEAL_MTLS_ENABLED=1, сертификаты из DEAL_MTLS_*)" : "plaintext + service-token (dev)");
-
-if (forwardedHeadersConfig.Enabled)
-{
- // Прокси-заголовки (план Task 12; замечание ревью T4/T11): X-Forwarded-For/X-Forwarded-Proto доверяются
- // только клиентам из ForwardedHeaders:KnownProxies/KnownNetworks (конфиг; appsettings — loopback для dev).
- // Middleware — ПЕРВЫЙ в конвейере: RemoteIpAddress/Scheme читают слои ниже (CORS, Session/OperatorSession —
- // audit-IP эндпоинтов, RateLimiter — ключи по IP, LoginAttemptGuard). Без него за Caddy (compose-prod,
- // Task 14) RemoteIpAddress всех запросов = IP Caddy, и audit-IP + rate-limit-по-IP схлопываются в один бакет.
- app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
-}
-
-// Access-лог HTTP (Ruling 7, Task 14): первый в конвейере (после ForwardedHeaders) — длительность
-// и статус всего пути обработки. gRPC-ингресс (Content-Type application/grpc) middleware пропускает —
-// его логирует интерцептор RpcCallLoggingInterceptor (см. HttpAccessLogMiddleware).
-app.UseMiddleware();
-
-app.UseCors(corsPolicyName);
-app.UseMiddleware();
-app.UseMiddleware();
-if (rateLimitOptions.Enabled)
-{
- // Порядок middleware — Ruling 5: Session → Operator → RateLimiter (политика "api" ключует по
- // CurrentUser.TenantId либо IP анонима; сессии уже разрешены). При Enabled=false лимитер не
- // регистрируется (dev-прогон не режет curl-приёмки; OriginGuard Task 12 встанет после).
- app.UseRateLimiter();
-}
-
-// Origin-проверка мутаций /api (план Task 12, Ruling 10(2)): порядок Ruling 5 — Session → Operator →
-// RateLimiter → OriginGuard (сессии разрешены, 429 важнее 403). Проверяются не-GET/HEAD/OPTIONS запросы
-// с заголовком Origin: Origin == «свой» origin (схема + Host; за Caddy — https из X-Forwarded-Proto)
-// либо входит в Security:AllowedOrigins; иначе 403 {detail}. Без Origin (curl/сервер-сервер) пропускаются;
-// SameSite=Lax куки остаётся первым рубежом CSRF (фиксируется в техдок §10, Task 16).
-app.UseMiddleware();
-
-app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" }));
-app.MapAuthEndpoints();
-app.MapOperatorAuthEndpoints();
-app.MapOperatorAuditEndpoints();
-// Операторская аналитика (план этапа 10, T3): read-only сводка/расход токенов/лента действий.
-app.MapOperatorAnalyticsEndpoints();
-app.MapOperatorInvitesEndpoints();
-app.MapOperatorTenantsEndpoints();
-// Операторские лимиты/health (план Task 10, Ruling 3/11): сводка и смена бюджета по тенанту (GET/PATCH
-// .../limit, аудит tenant_limit_changed) + health ядра/БД и сервисов ml/ai/telegram.
-app.MapOperatorLimitsEndpoints();
-app.MapOperatorHealthEndpoints();
-// Глобальные настройки оператора (ТЗ §4.1/§8.1): ключи приложения Telegram — чтение (маска) и смена.
-app.MapOperatorSettingsEndpoints();
-// Обслуживание (этап 12, пакет C): идемпотентная пакетная миграция схем всех тенантов реестра
-// (ограниченный параллелизм + логирование прогресса) — для SaaS с сотнями/тысячами схем.
-app.MapOperatorMaintenanceEndpoints();
-app.MapJoinEndpoint();
-app.MapSettingsEndpoints();
-app.MapAiCheckEndpoint();
-app.MapRatesEndpoints();
-app.MapMlEndpoints();
-app.MapFilterTesterEndpoints();
-app.MapContainersEndpoints();
-app.MapCardsEndpoints();
-app.MapCardDetailsEndpoints();
-app.MapStorageEndpoints();
-app.MapEventsEndpoint();
-app.MapAiSuggestEndpoints();
-app.MapPipelineEndpoints();
-// Эндпоинты /api/tg (план Task 14, Ruling 8): реальный контракт вкладки «Каналы» вместо boot-заглушки
-// (BootStubEndpoints удалён); qr-image — отдельным файлом.
-app.MapTelegramEndpoints();
-app.MapTelegramQrImageEndpoint();
-// Эндпоинты /api/discovery (план Task 19, Ruling 11): задачи поиска/кандидаты/чёрный список/лог/generate-keywords
-// (DiscoveryEndpoints, 1:1 api-map §3.8) — модуль Discovery зарегистрирован AddDiscoveryModule выше.
-app.MapDiscoveryEndpoints();
-// gRPC-ингресс и его health освобождены от HTTP-политик rate limiter (план Task 11, Ruling 5): лимит
-// входящего потока считает IngressRateLimitInterceptor по tenant-id из metadata (иначе общее окно на IP
-// telegram-service резало бы весь ингресс раньше интерцептора); health — инфраструктурный liveness.
-app.MapGrpcService().DisableRateLimiting();
-app.MapGrpcHealthChecksService().DisableRateLimiting();
-
-app.Run();
-
-// ── Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (явные Listen заменяют URL-биндинг) ──
-
-// Биндит основной HTTP-эндпоинт(ы) из ключа "urls" (--urls/ASPNETCORE_URLS/launchSettings): без явных
-// адресов используется фолбэк defaultHttpUrl (как дефолт ASP.NET Core). Схема https — сертификат из
-// стандартной конфигурации Kestrel (UseHttps без аргументов), как при URL-биндинге.
-static void BindMainHttpEndpoints(KestrelServerOptions kestrel, string? urlsConfig)
-{
- List addresses = ParseHttpAddresses(urlsConfig);
- if (addresses.Count == 0)
- {
- addresses.Add(new Uri(defaultHttpUrl));
- }
-
- foreach (Uri address in addresses)
- {
- // Dev-запуски используют loopback («localhost»/127.0.0.1) — ListenLocalhost; «0.0.0.0»/«*»/«+»/«::»
- // (compose/host) — все интерфейсы. Иных хостов в конфигурации нет (URL-хосты не биндятся по имени).
- bool isHttps = string.Equals(address.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
- string host = address.Host.Trim('[', ']');
- if (host is "0.0.0.0" or "*" or "+" or "::")
- {
- kestrel.Listen(IPAddress.Any, address.Port, listen =>
- {
- if (isHttps)
- {
- listen.UseHttps();
- }
- });
- }
- else if (isHttps)
- {
- kestrel.ListenLocalhost(address.Port, listen => listen.UseHttps());
- }
- else
- {
- kestrel.ListenLocalhost(address.Port);
- }
- }
-}
-
-// Разбирает адреса конфигурации "urls" (http/https, разделитель «;»); невалидные/чужие схемы пропускаются.
-static List ParseHttpAddresses(string? urlsConfig)
-{
- var addresses = new List();
- if (string.IsNullOrWhiteSpace(urlsConfig))
- {
- return addresses;
- }
-
- foreach (string part in urlsConfig.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
- {
- if (Uri.TryCreate(part, UriKind.Absolute, out Uri? uri)
- && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
- {
- addresses.Add(uri);
- }
- }
-
- return addresses;
-}
-
-// Парсит порт из env-строки; пустое/нечисловое значение — null (фолбэк дефолта).
-static int? ParsePort(string? rawValue)
- => int.TryParse(rawValue, out int parsedPort) ? parsedPort : null;
-
-// Дефолт-бюджет нового тенанта из env DEAL_DEFAULT_AI_BUDGET (Ruling 3, Task 8): нечисловое/неположительное
-// значение (пустая переменная, опечатка) — константа модуля TokenBudgetDefaults.DefaultBudgetTokens. Период
-// всегда month — оператор меняет бюджет/период позже через PATCH лимита (Task 10).
-long ResolveDefaultAiBudget(IConfiguration configuration)
-{
- string? rawValue = configuration[defaultAiBudgetEnvKey];
- return long.TryParse(rawValue, out long parsedBudget) && parsedBudget > 0
- ? parsedBudget
- : TokenBudgetDefaults.DefaultBudgetTokens;
-}
-
-public partial class Program
-{
- ///
- /// Строит опции UseForwardedHeaders из ForwardedHeadersConfig (план Task 12; замечание ревью
- /// T4/T11): обрабатываются X-Forwarded-For/X-Forwarded-Proto ровно одного доверенного hop'а. Списки
- /// доверия — строго из конфига KnownProxies/KnownNetworks (appsettings-дефолт — loopback); невалидный
- /// IP/CIDR — InvalidOperationException (fail-fast: опечатка в настройке доверия не должна молча
- /// отключать обработку). Публичный: unit-тесты опций (ForwardedHeadersHttpTests).
- ///
- /// Секция ForwardedHeaders конфигурации.
- /// Опции для app.UseForwardedHeaders.
- public static ForwardedHeadersOptions BuildForwardedHeadersOptions(ForwardedHeadersConfig config)
- {
- ArgumentNullException.ThrowIfNull(config);
- var options = new ForwardedHeadersOptions
- {
- // Адрес клиента и схема (Origin-проверка «своего» origin за Caddy требует https из
- // X-Forwarded-Proto). X-Forwarded-Host не нужен: Caddy транслирует Host без перезаписи.
- ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
- // Между клиентом и core ровно один прокси (Caddy): читаем ближайший к нам элемент цепочки.
- ForwardLimit = 1,
- };
- // Доверие — только явный конфиг + loopback-фолбэк ниже: очищаем дефолты конструктора, чтобы
- // перечисление в KnownProxies/KnownIPNetworks было единственным источником истины.
- options.KnownProxies.Clear();
- options.KnownIPNetworks.Clear();
- foreach (string proxy in config.KnownProxies)
- {
- if (!IPAddress.TryParse(proxy, out IPAddress? address))
- {
- throw new InvalidOperationException(
- $"Невалидный IP доверенного прокси в ForwardedHeaders:KnownProxies: \"{proxy}\".");
- }
-
- options.KnownProxies.Add(address);
- }
-
- foreach (string network in config.KnownNetworks)
- {
- if (!TryParseCidr(network, out System.Net.IPNetwork parsedNetwork))
- {
- throw new InvalidOperationException(
- $"Невалидная подсеть доверенного прокси в ForwardedHeaders:KnownNetworks: \"{network}\" "
- + "(ожидается CIDR, напр. 172.16.0.0/12).");
- }
-
- options.KnownIPNetworks.Add(parsedNetwork);
- }
-
- if (options.KnownProxies.Count == 0 && options.KnownIPNetworks.Count == 0)
- {
- // Пустые списки KnownProxies/KnownIPNetworks у ForwardedHeadersMiddleware означают «доверять
- // ЛЮБОМУ клиенту» (спуфинг XFF) — пустоту до middleware не допускаем. Дефолт без явного
- // конфига — loopback (dev-прокси на хосте: vite/локальный Caddy, см. appsettings); PROD
- // перечисляет Caddy адресами/подсетями в конфиге.
- options.KnownProxies.Add(IPAddress.Loopback);
- options.KnownProxies.Add(IPAddress.IPv6Loopback);
- }
-
- return options;
- }
-
- // Разбирает CIDR-запись подсети ("127.0.0.0/8", "2001:db8::/32"); без префикса — вся
- // подсеть одного адреса (IPv4 — /32, IPv6 — /128).
- // value: Строка конфига (ForwardedHeaders:KnownNetworks).
- // network: Разобранная подсеть при успехе.
- // Возвращает: true — запись валидна.
- private static bool TryParseCidr(string value, out System.Net.IPNetwork network)
- {
- network = default;
- int slashIndex = value.IndexOf('/');
- string addressPart = slashIndex >= 0 ? value[..slashIndex] : value;
- if (!IPAddress.TryParse(addressPart, out IPAddress? address))
- {
- return false;
- }
-
- int maxPrefixLength = address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128;
- int prefixLength = maxPrefixLength;
- if (slashIndex >= 0)
- {
- string lengthPart = value[(slashIndex + 1)..];
- if (!int.TryParse(lengthPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength)
- {
- return false;
- }
- }
-
- network = new System.Net.IPNetwork(address, prefixLength);
- return true;
- }
-}
+using System.Net;
+using System.Net.Sockets;
+using System.Text.Encodings.Web;
+using Deal.Api;
+using Deal.Api.Configuration;
+using Deal.Api.Endpoints;
+using Deal.Api.Events;
+using Deal.Api.Hosting;
+using Deal.Api.Http;
+using Deal.Api.Logging;
+using Deal.Api.Middleware;
+using Deal.Api.Observability;
+using Deal.Api.Telegram;
+using Deal.Contracts.Integrations;
+using Deal.Infrastructure;
+using Deal.Infrastructure.Data;
+using Deal.Infrastructure.Integrations;
+using Deal.Infrastructure.Integrations.Storage;
+using Deal.Infrastructure.Persistence;
+using Deal.Infrastructure.Services;
+using Deal.Modules.Discovery.Application.Abstractions;
+using Deal.Modules.Discovery.Application.Exceptions;
+using Deal.Modules.Discovery.Application.Extensions;
+using Deal.Modules.Discovery.Application.Models;
+using Deal.Modules.Discovery.Application.Registrars;
+using Deal.Modules.Discovery.Application.Services;
+using Deal.Modules.Kanban.Application.Abstractions;
+using Deal.Modules.Kanban.Application.Extensions;
+using Deal.Modules.Kanban.Application.Models;
+using Deal.Modules.Kanban.Application.Registrars;
+using Deal.Modules.Kanban.Application.Services;
+using Deal.Modules.Pipeline.Application.Abstractions;
+using Deal.Modules.Pipeline.Application.Models;
+using Deal.Modules.Pipeline.Application.Registrars;
+using Deal.Modules.Pipeline.Application.Services;
+using Deal.Modules.Settings.Application.Abstractions;
+using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
+using Deal.Modules.Telegram.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
+using Deal.SharedKernel.Tenants;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
+using Microsoft.AspNetCore.Server.Kestrel.Https;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
+using CookieOptions = Deal.Api.Configuration.CookieOptions;
+
+const string cookiesSectionName = "Cookies";
+// Секция настроек куки оператора (Ruling 1): имя deal_operator_session, срок 12 ч, Secure из конфига.
+const string operatorCookiesSectionName = "OperatorCookies";
+// Имя CORS-политики (план Task 12, Ruling 10): одна политика, режим зависит от Security:AllowedOrigins.
+const string corsPolicyName = "cors";
+// Секции конфигурации интеграций (Ruling 6): Services:Ml / Services:Ai / Services:Telegram → {UseLocal, Endpoint}.
+const string servicesSectionName = "Services:Ml";
+const string aiServicesSectionName = "Services:Ai";
+const string telegramServicesSectionName = "Services:Telegram";
+// Имя истории tenant-миграций без схемы (схема — через search_path; миграции применяет
+// TenantProvisioningService на старте, runtime-контекст их не выполняет).
+const string tenantMigrationsHistoryTable = "__TenantMigrationsHistory";
+// Порт gRPC-ингресса по умолчанию (Ruling 7): :5082; переопределяется env GRPC_INGRESS_PORT.
+const int defaultIngressPort = 5082;
+const string ingressPortEnvKey = "GRPC_INGRESS_PORT";
+// Ключ конфигурации адресов основного HTTP-эндпоинта (--urls/ASPNETCORE_URLS/launchSettings).
+const string serverUrlsKey = "urls";
+// Фолбэк основного HTTP-адреса при отсутствии явных URL (дефолт ASP.NET Core http://localhost:5000).
+const string defaultHttpUrl = "http://localhost:5000";
+// Ключ env-переопределения дефолт-бюджета нового тенанта (Ruling 3; токенов в месяц, период — всегда month).
+const string defaultAiBudgetEnvKey = "DEAL_DEFAULT_AI_BUDGET";
+// Секция настроек rate limiting (план Task 11, Ruling 5): RateLimit:Enabled=false в dev/тестах по
+// умолчанию (curl-приёмки не режутся); PROD включает env-переопределением RateLimit__Enabled=true
+// (compose-prod, Task 14).
+const string rateLimitSectionName = "RateLimit";
+// Секция авто-очистки данных (этап 12, пакет B): DataRetention:AuditRetentionDays (дефолт 180) + Enabled —
+// фоновый цикл DataRetentionScheduler чистит audit_log по retention, лимиты/счётчики прошедших окон.
+const string dataRetentionSectionName = "DataRetention";
+// Секция настроек безопасности HTTP (план Task 12, Ruling 10): Security:AllowedOrigins — явный allowlist
+// Origin-проверки/CORS (пусто — dev-режим «свой origin» + CORS-любой; PROD — домены фронта, Ruling 9).
+const string securitySectionName = "Security";
+// Секция доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11): ForwardedHeaders:KnownProxies/
+// KnownNetworks — доверенные прокси (Caddy в PROD); dev-дефолт в appsettings — loopback.
+const string forwardedHeadersSectionName = "ForwardedHeaders";
+
+// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-core-<дата>.json.
+const string coreProcessName = "core";
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Структурированные логи Serilog (Ruling 7, план Task 14): консоль JSON в prod / текст в dev +
+// rolling-файл data/logs/deal-core-*.json (data — volume контейнера). Уровень/каталог — env
+// DEAL_LOG_LEVEL/DEAL_LOGS_DIR (см. DealLogging). Регистрируется до остальных сервисов: логирование
+// заменяет провайдеры Microsoft при builder.Build().
+DealLogging.Configure(builder, coreProcessName);
+
+// Метрики OpenTelemetry → Prometheus (этап 12, пакет A): эндпоинт /metrics в формате Prometheus на
+// отдельном HTTP/1.1 Kestrel-эндпоинте (порт 9464/env METRICS_PORT) + инструментация входящих HTTP-
+// запросов и исходящих HTTP-клиентов; прикладные метрики — SharedKernel/Observability/DealMetrics.
+int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
+DealMetricsHosting.AddDealMetrics(builder, metricsPort);
+
+// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует
+// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД.
+var connectionString = builder.Configuration.GetConnectionString("DealPostgres")
+ ?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
+builder.Services.AddDbContext(options => options.UseNpgsql(connectionString));
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+// mTLS внутреннего gRPC-транспорта (Ruling 6, план Task 13): env DEAL_MTLS_* — флаг и пути/пароли
+// сертификатов (только env, Ruling 13). Dev-дефолт — выключено: сервисы и ингресс остаются на plaintext +
+// service-token (Ruling 2 этапа 6); compose-prod (Task 14) передаёт env и монтирует deploy/certs
+// (генерация — scripts/mtls-certs.sh). При DEAL_MTLS_ENABLED=1 сертификаты грузятся сразу (fail-fast на
+// битые пути/пароли) — один экземпляр используют и Kestrel-ингресс ниже, и gRPC-клиенты
+// (Ml/Ai/Telegram-каналы + ServiceHealthProbe).
+MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(builder.Configuration);
+MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(mtlsOptions);
+if (mtlsCertificates is not null)
+{
+ builder.Services.AddSingleton(mtlsCertificates);
+}
+
+// Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (как раньше — --urls/ASPNETCORE_URLS/
+// launchSettings) + второй endpoint gRPC-ингресса telegram-service (:5082, HTTP/2, env GRPC_INGRESS_PORT;
+// план Task 12, Ruling 7). Явные Listen заменяют URL-биндинг Kestrel, поэтому основной эндпоинт
+// пере-биндим адресами конфигурации "urls" явно (см. BindMainHttpEndpoints ниже). Ingress слушает все
+// интерфейсы (AnyIP): в dev к нему ходит telegram-service из compose-сети через host.docker.internal (Ruling 12).
+builder.WebHost.ConfigureKestrel(kestrel =>
+{
+ BindMainHttpEndpoints(kestrel, builder.Configuration[serverUrlsKey]);
+ int ingressPort = ParsePort(builder.Configuration[ingressPortEnvKey]) ?? defaultIngressPort;
+ kestrel.ListenAnyIP(ingressPort, listen =>
+ {
+ listen.Protocols = HttpProtocols.Http2;
+ if (mtlsCertificates is not null)
+ {
+ // mTLS-ингресс (Ruling 6, Task 13): HTTPS с серверным сертификатом core + обязательный клиентский
+ // сертификат (цепочка до CA из DEAL_MTLS_CA_PEM). Основной HTTP :5080 остаётся http — TLS наружу
+ // терминирует Caddy (Ruling 9, compose-prod Task 14).
+ listen.UseHttps(https =>
+ {
+ https.ServerCertificate = mtlsCertificates.ServerCertificate;
+ https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
+ https.ClientCertificateValidation = mtlsCertificates.ValidateClientCertificate;
+ });
+ }
+ });
+});
+
+// TenantDbContext — scoped-контекст бессхемной модели тенанта (таблица settings и др.): строка
+// подключения на каждый scope строится по текущему ITenantContext (заполняет SessionMiddleware)
+// с Search Path на схему тенанта (ConnectionStringProvider.ForTenant). Опции живут в scope запроса
+// (optionsLifetime: Scoped) — иначе опции с первым тенантом закешировались бы в singleton.
+// Безопасность: вне tenant-запроса (нет сессии) контекст не имеет смысла — ошибка конфигурации.
+builder.Services.AddDbContext(
+ (serviceProvider, options) =>
+ {
+ var tenantContext = serviceProvider.GetRequiredService();
+ if (!tenantContext.HasTenant)
+ {
+ throw new InvalidOperationException(
+ "TenantDbContext запрошен вне tenant-запроса: на запрос не разрешена сессия "
+ + "(ITenantContext.HasTenant == false).");
+ }
+
+ var connectionStringProvider = serviceProvider.GetRequiredService();
+ options.UseNpgsql(
+ connectionStringProvider.ForTenant(tenantContext.TenantId),
+ npgsql => npgsql.MigrationsHistoryTable(tenantMigrationsHistoryTable));
+ },
+ contextLifetime: ServiceLifetime.Scoped,
+ optionsLifetime: ServiceLifetime.Scoped);
+
+// Модуль Tenants и его EF-адаптеры («port & adapter», Ruling 1).
+builder.Services.AddTenantsModule();
+
+// Лимиты ИИ-бюджета (Ruling 3, Task 8): дефолт-бюджет лениво создаваемой строки public.tenant_limits —
+// env DEAL_DEFAULT_AI_BUDGET (токенов в месяц) с фолбэком на константу модуля TokenBudgetDefaults (10 000 000);
+// период нового тенанта — month (константа). Значение читается один раз на старте и передаётся адаптеру
+// TenantLimitStore (GetOrCreateAsync при первом чтении/списании, задачи 7/10 list-путь тоже закрыт).
+TokenLimitDefaults tenantLimitDefaults = new(
+ ResolveDefaultAiBudget(builder.Configuration), TokenBudgetDefaults.DefaultPeriod);
+builder.Services.AddDealPersistence(tenantLimitDefaults);
+
+// Шифрование секретов (Ruling 2): ISecretCipher — AES-256-GCM; ключ из DEAL_ENCRYPTION_KEY
+// либо файла data/encryption.key под ContentRoot (dev). Ключ разрешается на старте —
+// невалидный env-ключ останавливает запуск.
+builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
+
+// Внешние интеграции (Tasks 9/16/15, Rulings 4/5/6/9): IMlClient — детерминированная заглушка LocalMlClient
+// (Services:Ml:UseLocal=true, default; обучение — этап 3) либо gRPC-клиент GrpcMlClient (UseLocal=false,
+// ml-service :5103). Local-адаптеры читают KV-настройки тенанта через ISettingsStore — scoped (вне
+// tenant-запроса не разрешимы). IColumnSuggester — LocalColumnSuggester (эвристика, Self-Review L525–527).
+MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get() ?? new MlServiceOptions();
+builder.Services.AddSingleton(mlOptions);
+// AI-интеграция (план Task 15, Ruling 6/9): IAiClassifier/IAiTools — Local-адаптеры (Services:Ai:UseLocal=true,
+// default; локальный разбор ядра / инструменты не поддерживаются) либо декораторы бюджетного гейта поверх
+// gRPC-клиентов ai-service (UseLocal=false, ai-service :5102; AddDealIntegrations регистрирует GrpcAiClassifier/
+// GrpcAiTools + транспорт AiGrpcConnection — fail-fast, как MlGrpcConnection — и оборачивает их в
+// BudgetedAiClassifier/BudgetedAiTools, Ruling 3/Task 9).
+AiServiceOptions aiOptions = builder.Configuration.GetSection(aiServicesSectionName).Get() ?? new AiServiceOptions();
+builder.Services.AddSingleton(aiOptions);
+// Telegram-гейт (план Task 14, Ruling 6/7): LocalTelegramGateway (Services:Telegram:UseLocal=true, default —
+// нейтральный no-op/idle) либо gRPC-клиент GrpcTelegramClient (UseLocal=false, telegram-service :5101).
+TelegramServiceOptions telegramOptions = builder.Configuration.GetSection(telegramServicesSectionName).Get() ?? new TelegramServiceOptions();
+builder.Services.AddSingleton(telegramOptions);
+builder.Services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions, mtlsCertificates);
+
+// Health-проба автономных сервисов для операторского health (план Task 10, Ruling 3/6/9): grpc.health.v1
+// к Services:*:Endpoint с дедлайном 3 с (ServiceHealthProbe). Stateless, singleton — пробы строят
+// короткоживущие каналы на каждый вызов (mTLS-каналы — при включённом флаге, Task 13/Ruling 6).
+builder.Services.AddSingleton(new ServiceHealthProbe(mtlsCertificates));
+
+// Файловое хранилище вложений проектных карточек (Ruling 4, Task 6): LocalFileStorage (data/attachments
+// под ContentRoot) — dev/curl/unit по умолчанию; MinioFileStorage регистрируется, только когда сконфигурирован
+// MinIO (секция Storage:Minio либо env-алиасы DEAL_MINIO_*; compose-сервис deal-minio, порты 9000/9001).
+// Singleton: хранилище не привязано к схеме тенанта (объекты — в едином бакете/каталоге, мульти-аренда
+// объектного хранилища — этап 7 SaaS). Режим логируется на старте (см. ниже) — приёмка Task 6.
+builder.Services.AddDealFileStorage(builder.Configuration, builder.Environment.ContentRootPath);
+
+// Модуль Settings (сервис настроек тенанта); адаптеры ISettingsStore/ISecretCipher уже
+// зарегистрированы AddDealPersistence/AddDealSecurity выше (см. Task 4).
+builder.Services.AddSettingsModule();
+
+// Модуль Kanban — единый домен карточки (Ruling 12): регистратор сервисов карточек/контейнеров/тиков;
+// порт-адаптер ICardStore → KanbanStore уже зарегистрирован AddDealPersistence.
+builder.Services.AddKanbanModule();
+
+// Модуль Pipeline (Ruling 10, Task 9): приём/обработка/воркер и ядра разбора этапа 4. Порт-адаптер
+// IPipelineStore → PipelineStore и внешние порты (IMlClient/IAiClassifier) уже зарегистрированы
+// AddDealPersistence/AddDealIntegrations выше; сервисы модуля вызывают из эндпоинтов /api/pipeline/*
+// и gRPC-ингресса telegram-service, pump — admin/tick (Task 10) и фоновый цикл (Task 11).
+builder.Services.AddPipelineModule();
+
+// Модуль Telegram (план Task 13, Ruling 7): сервис каталога диалогов (DialogsService) — владелец таблиц
+// Dialogs/TgMessages схемы тенанта (миграция TenantTelegram). Порт-адаптеры ITelegramStore → TelegramStore и
+// ITelegramGateway → LocalTelegramGateway/GrpcTelegramClient зарегистрированы AddDealPersistence/AddDealIntegrations
+// выше; сервис зовут gRPC-ингресс (SyncDialogs/PushMessage) и эндпоинты /api/tg (Task 14).
+builder.Services.AddTelegramModule();
+
+// Модуль Discovery (план Task 17/18, Ruling 9/10): сервисы задач/кандидатов/чёрного списка/лога, план-бюджет
+// и воркер (оценка/бан-гард/паузы). Порт-адаптер IDiscoveryStore → DiscoveryStore зарегистрирован
+// AddDealPersistence; внешние порты (ITelegramGateway/IAiTools/IMlClient) — AddDealIntegrations выше. Эндпоинты
+// /api/discovery добавляет Task 19; фоновый цикл воркера — DiscoveryWorkerScheduler ниже.
+builder.Services.AddDiscoveryModule();
+
+// Статус/ключи вкладки Telegram (план Task 14, Ruling 8): сборка GET /api/tg/status (гейт + KV tgAccount +
+// счётчик мониторящихся + keysSet) и чтение глобальных ключей приложения (telegramKeys в public.global_settings,
+// расшифровка apiHash — задаёт оператор, ТЗ §4.1/§8.1). Scoped: зависимости — ISettingsStore/ITelegramStore
+// на TenantDbContext схемы тенанта запроса, IGlobalSettingsStore — на системном DealDbContext.
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+// Фоновые спуски перечитывания каналов (план Task 14, Ruling 8; аналог python-_spawn роутеров): эндпоинты
+// мониторинга/«Перечитать» отвечают сразу, тяжёлый разбор идёт в отдельном scope с захваченным tenant-контекстом.
+builder.Services.AddSingleton();
+
+// Оркестратор ручного тика (план Task 10, Rulings 8/9): тик правил хранения (Kanban) + очистка отсева
+// Pipeline + один проход pump + SSE-публикации (тосты/new_card) для POST /api/admin/tick (StorageEndpoints).
+// Scoped: зависимости живут в рамках tenant-запроса (scoped-сервисы модулей на TenantDbContext схемы).
+builder.Services.AddScoped();
+
+// Обслуживание FTS-индексов схемы тенанта (Ruling 6, план Task 10): POST /api/admin/fts/rebuild —
+// CREATE INDEX IF NOT EXISTS + ANALYZE (FtsMaintenance) на TenantDbContext запроса (scoped, как адаптеры).
+builder.Services.AddScoped();
+
+// SSE-брокер этапа (Ruling 5): singleton per-tenant каналов событий; подписка — GET /api/events,
+// публикации — из эндпоинтов Api после вызова сервисов (Tasks 10/13/14).
+builder.Services.AddSingleton();
+
+// SSE-тосты статистики тика правил хранения (Ruling 8): единый хелпер для POST /api/admin/tick
+// (StorageEndpoints) и фонового StorageTickScheduler (Task 11) — без дублирования текстов/иконок.
+builder.Services.AddSingleton();
+
+// Общий воркер-гейт pump тенанта (план Task 11, Ruling 8; аналог asyncio.Lock pipeline.py L38–42):
+// POST /api/admin/tick (AdminTickOrchestrator) и фоновый цикл разбора очереди (PipelineWorkerScheduler)
+// не разбирают очередь одного тенанта одновременно (singleton per-tenant флагов, Interlocked).
+builder.Services.AddSingleton();
+
+// Rate limiting и защита входа (план Task 11, Ruling 5; этап 12, пакет B — хранилище на Postgres): секция
+// "RateLimit" (appsettings.json + env RateLimit__*). Enabled=false в dev/тестах — политики/middleware/
+// интерцептор не регистрируются вовсе (Ruling 5: «в dev выключено — curl-приёмки не режутся»);
+// LoginAttemptGuard (окно ip|login 5 неудач/15 мин в public.rate_limit_counters) регистрируется всегда,
+// но активен только при Enabled.
+RateLimitOptions rateLimitOptions = builder.Configuration
+ .GetSection(rateLimitSectionName)
+ .Get() ?? new RateLimitOptions();
+builder.Services.AddSingleton(rateLimitOptions);
+// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер
+// (public.rate_limit_counters). Активен только при Enabled (no-op иначе).
+builder.Services.AddScoped();
+if (rateLimitOptions.Enabled)
+{
+ builder.Services.AddDealRateLimiter(rateLimitOptions);
+}
+
+// gRPC-ингресс telegram-service (план Task 12, Ruling 1/7): сервер Deal.Grpc.Telegram.IngressService
+// на отдельном Kestrel-endpoint (:5082, HTTP/2, см. ConfigureKestrel выше) в том же процессе. Token
+// из metadata «service-token» проверяет интерцептор (fail-closed, DEAL_SERVICE_TOKEN); AddAuthentication
+// не нужен — пользовательская сессия HTTP ингрессом не используется (tenant-id из metadata → SetTenant).
+builder.Services.AddGrpc(grpc =>
+{
+ // Access-лог RPC ингресса (Ruling 7, Task 14): ПЕРВЫМ в цепочке — логируются и отклонённые
+ // вызовы (401/429); gRPC-health не логируется (см. RpcCallLoggingInterceptor).
+ grpc.Interceptors.Add();
+ grpc.Interceptors.Add();
+ if (rateLimitOptions.Enabled)
+ {
+ // Лимит входящего потока по tenant-id (план Task 11, Ruling 5): окно считает общий
+ // singleton-лимитер (CreateLimiter) — экземпляры интерцептора общий PartitionedRateLimiter
+ // разделяют; health-методы освобождены (см. IngressRateLimitInterceptor).
+ grpc.Interceptors.Add();
+ }
+});
+if (rateLimitOptions.Enabled)
+{
+ builder.Services.AddSingleton(provider =>
+ IngressRateLimitInterceptor.CreateLimiter(
+ provider.GetRequiredService(),
+ rateLimitOptions.GrpcIngressPerMinute));
+}
+
+builder.Services.AddScoped();
+
+// gRPC-health ингресса (план Task 20, Ruling 12): healthcheck контейнера core в docker compose.
+// grpc.health.v1.Health интерцептор токеном не проверяет (инфраструктурный liveness, как в сервисах
+// этапа T2–T4); регистрируется явная проверка "ready" — без неё health-сервис отвечает UNKNOWN.
+// Живучесть интеграций (ml/ai/telegram) health не проверяет — недоступность сервиса это UNAVAILABLE
+// на RPC и фолбэк Local-адаптеров, а не падение хоста (Ruling 6).
+builder.Services
+ .AddGrpcHealthChecks()
+ .AddCheck("ready", () => HealthCheckResult.Healthy("хост Deal.Api готов"));
+
+// Проверка подключения AI-провайдера (Task 6, Ruling 7): порт модуля IAiConnectionChecker →
+// HTTP-адаптер Infrastructure с собственным HttpClient (фабрика AddHttpClient, таймаут 12 с).
+// HTTP наружу ходит только по действию Settings-экрана (POST /api/ai/check) — GET {base}/models.
+builder.Services.AddHttpClient(
+ client => client.Timeout = TimeSpan.FromSeconds(AiConnectionChecker.RequestTimeoutSeconds));
+
+// Курсы валют (Task 8, Ruling 6): порт модуля IRatesSource → HTTP-адаптер ЦБ с собственным
+// HttpClient (таймаут 15 с, как httpx timeout=15 в rates.py). URL — фиксированная константа
+// адаптера (SSRF-allowlist), источник тенантом не настраивается. Типизированный клиент
+// регистрируется transient и живёт в рамках scope запроса (как IAiConnectionChecker, Task 6).
+builder.Services.AddHttpClient(
+ client => client.Timeout = TimeSpan.FromSeconds(CbrRateSource.RequestTimeoutSeconds));
+
+// Фоновое обновление кэша курсов вне запроса (Ruling 6): PATCH rateSource и лениво на GET —
+// собственный scope + in-flight guard (см. RatesRefreshScheduler).
+builder.Services.AddSingleton();
+
+// Bootstrap при старте (Ruling 8): дефолтный тенант + admin, провижининг схем всех тенантов.
+builder.Services.AddHostedService();
+
+// Bootstrap оператора при старте (Ruling 1 этапа 7): env DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD,
+// dev-дефолт operator/operator в Development; в Production без env — warning и пропуск. Идёт после
+// TenantBootstrapService: операторские public-таблицы не зависят от провижининга схем тенантов.
+builder.Services.AddHostedService();
+
+// Фоновый цикл правил хранения (план Task 11, Ruling 8; аналог _storage_loop main.py L43–53): каждые
+// 30 с тикает ВСЕ тенанты (StorageTickService + автоочистка отсева пайплайна 3 суток) и публикует
+// SSE-тосты. Регистрируется после Bootstrap — первый проход стартует уже после провижининга схем.
+builder.Services.AddHostedService();
+
+// Фоновый цикл SSE-алертов ИИ-бюджета (план Task 9, Ruling 3; эталон StorageTickScheduler): каждые 60 с
+// проверяет ВСЕ тенанты и публикует в канал тенанта тост при пересечении порогов 80/100% (TryMark*-CAS —
+// один тост на порог за период). Идёт после Bootstrap: реестр тенантов провижинен до первого прохода.
+builder.Services.AddHostedService();
+
+// Фоновый цикл разбора очереди входящих (план Task 11, Ruling 8/11; аналог _pipeline_loop main.py L79–88):
+// каждые 2 с pump'ит ВСЕ тенанты (PipelineWorkerService.PumpOnceAsync под общим PipelinePumpGate) и
+// публикует SSE new_card по созданным карточкам — ingest разбирается без ручного tick (приёмка Task 11).
+// После StorageTickScheduler: очередь цикла — 2 с, первый проход сразу после старта.
+builder.Services.AddHostedService();
+
+// Фоновый флашер очереди обучения ML (план Task 16, Ruling 6; аналог _ml_sync_loop main.py): каждые 10 с
+// выгружает MlOutbox тенантов в ml-service (TrainBatch, порции по 10, ≤100/цикл; удаление после успеха).
+// Регистрируется только в gRPC-режиме (UseLocal=false) — Local-режиму (этапы 2–5) сервис не нужен, очередь
+// копится до подключения ml-service (python L56–82); MlGrpcConnection создан в AddDealIntegrations (fail-fast).
+if (!mlOptions.UseLocal)
+{
+ builder.Services.AddHostedService();
+}
+
+// Фоновый цикл Discovery-воркера (план Task 18, Ruling 10; аналог _discovery_loop main.py): каждые 5 с
+// делает ОДИН шаг (поиск/оценка/авто-вступление/done) для каждой running-задачи всех тенантов. Работает
+// всегда: в Local-режиме гейт нейтрален (поиск пуст/история недоступна), реальные действия — при
+// подключённом telegram-service (UseLocal=false). После Bootstrap: первый проход стартует после провижининга.
+builder.Services.AddHostedService();
+
+// Сборщик глубин очередей/сессий (этап 12, §10.2): общий источник для метрик и операторского health.
+builder.Services.AddSingleton();
+
+// Фоновый сборщик gauge-метрик (этап 12, пакет A): каждые 15 с публикует глубины очередей (пайплайн,
+// MlOutbox) по всем тенантам и число активных сессий в meter Deal (callback /metrics отдаёт их Prometheus).
+// Регистрируется последним из фоновых: после Bootstrap (реестр тенантов провижинен до первого прохода).
+builder.Services.AddHostedService();
+
+// Фоновый цикл авто-очистки данных (этап 12, пакет B; эталон DealMetricsCollector): раз в сутки удаляет
+// записи audit_log старше DataRetention:AuditRetentionDays (дефолт 180 дней), сбрасывает накопительные
+// поля лимитов прошедших периодов и убирает завершившиеся окна распределённых счётчиков. Регистрируется
+// последним из фоновых: после Bootstrap (реестр тенантов провижинен до первого прохода).
+DataRetentionOptions dataRetentionOptions = builder.Configuration
+ .GetSection(dataRetentionSectionName)
+ .Get() ?? new DataRetentionOptions();
+builder.Services.AddSingleton(dataRetentionOptions);
+builder.Services.AddHostedService();
+
+// Кука сессии: имя/срок/Secure из секции "Cookies" (appsettings.json + env Cookies__*).
+builder.Services.Configure(builder.Configuration.GetSection(cookiesSectionName));
+
+// Кука операторской сессии (Ruling 1 этапа 7): имя deal_operator_session/срок/Secure из секции
+// "OperatorCookies" (appsettings.json + env OperatorCookies__*) — отдельная от тенантной deal_session.
+builder.Services.Configure(builder.Configuration.GetSection(operatorCookiesSectionName));
+
+// Ответы JSON — как в прототипе FastAPI: без \u-экранирования не-ASCII символов.
+builder.Services.ConfigureHttpJsonOptions(options =>
+ options.SerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping);
+
+// Безопасность HTTP (план Task 12, Ruling 10(2)/9): Security:AllowedOrigins — явный allowlist Origin-
+// проверки мутаций и CORS (пусто — dev-режим «свой origin», см. AddCors ниже; PROD — домен фронта в
+// compose-prod). Инстанс регистрируется в DI: значение читается один раз на старте (политики формируются
+// при старте хоста), OriginGuardMiddleware получает его конструктором.
+SecurityOptions securityOptions = builder.Configuration
+ .GetSection(securitySectionName)
+ .Get() ?? new SecurityOptions();
+builder.Services.AddSingleton(securityOptions);
+
+// Доверие прокси-заголовкам (план Task 12; замечание ревью T4/T11): ForwardedHeaders — UseForwardedHeaders
+// включается env-переопределением (ForwardedHeaders__Enabled=true в PROD за Caddy, compose-prod Task 14);
+// dev-дефолт — false (прокси в dev-стеке нет, compose.dev публикует core напрямую).
+ForwardedHeadersConfig forwardedHeadersConfig = builder.Configuration
+ .GetSection(forwardedHeadersSectionName)
+ .Get() ?? new ForwardedHeadersConfig();
+builder.Services.AddSingleton(forwardedHeadersConfig);
+
+// CORS (план Task 12, Ruling 10(2)/9): пустой Security:AllowedOrigins — dev-режим «как в прототипе»
+// (любой origin/method/header, credentials=true; AllowAnyOrigin + AllowCredentials несовместимы — любой
+// origin разрешается предикатом). Непустой список (PROD, Ruling 9) — строгий allowlist + credentials.
+// Security-заголовки ответов (nosniff/X-Frame-Options/Referrer-Policy; CSP/HSTS) — на edge (Caddyfile,
+// Task 14): наружу статику и /api отдаёт Caddy, core отвечает JSON — на core не дублируются
+// (пересмотр Ruling 10(3), см. task-12-report).
+builder.Services.AddCors(options =>
+ options.AddPolicy(corsPolicyName, cors =>
+ {
+ cors.AllowAnyHeader()
+ .AllowAnyMethod()
+ .AllowCredentials();
+ if (securityOptions.AllowedOrigins.Length == 0)
+ {
+ cors.SetIsOriginAllowed(_ => true);
+ }
+ else
+ {
+ cors.WithOrigins(securityOptions.AllowedOrigins);
+ }
+ }));
+
+var app = builder.Build();
+
+// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A).
+DealMetricsHosting.MapDealMetrics(app);
+
+// Fail-closed для Production (Security review): дефолты кода рассчитаны на dev/тесты (rate limit выключен,
+// CORS — «любой origin»). Прод-окружение обязано задать защиту ЯВНО — иначе старт отказывается, а не
+// молча работает без лимитов/с открытым CORS.
+if (app.Environment.IsProduction())
+{
+ if (!rateLimitOptions.Enabled)
+ {
+ throw new InvalidOperationException(
+ "Production требует RateLimit__Enabled=true (анти-брутфорс и лимиты выключены код-дефолтом).");
+ }
+
+ if (securityOptions.AllowedOrigins.Length == 0)
+ {
+ throw new InvalidOperationException(
+ "Production требует непустой Security__AllowedOrigins (CORS fail-open при пустом списке).");
+ }
+}
+
+// Стартовый лог выбранного режима файлового хранилища (приёмка Task 6: запуск Api — LocalFileStorage
+// с путём data/attachments; при сконфигурированном MinIO — MinioFileStorage с endpoint/бакетом).
+app.Logger.LogInformation("Файловое хранилище: {FileStorage}", app.Services.GetRequiredService());
+
+// Стартовый лог режима ML-интеграции (приёмка Task 16): Local-заглушка либо gRPC-клиент ml-service.
+app.Logger.LogInformation(
+ "ML-интеграция: {Mode} ({Endpoint})",
+ mlOptions.UseLocal ? "Local-заглушка (MlOutbox накапливается)" : "gRPC-клиент ml-service",
+ mlOptions.Endpoint);
+
+// Стартовый лог режима AI-интеграции (приёмка Task 15): локальный разбор ядра либо gRPC-клиент ai-service
+// под декоратором бюджетного гейта (Task 9: исчерпано/приостановлено → локальный разбор, приём не блокируется).
+app.Logger.LogInformation(
+ "AI-интеграция: {Mode} ({Endpoint})",
+ aiOptions.UseLocal ? "Local-адаптеры (разбор ядра/инструменты выключены)" : "gRPC-клиент ai-service",
+ aiOptions.Endpoint);
+
+// Стартовый лог режима Telegram-гейта (приёмка Task 14): Local-заглушка либо gRPC-клиент telegram-service.
+app.Logger.LogInformation(
+ "Telegram-гейт: {Mode} ({Endpoint})",
+ telegramOptions.UseLocal ? "Local-заглушка (idle/не подключён)" : "gRPC-клиент telegram-service",
+ telegramOptions.Endpoint);
+
+// Стартовый лог транспорта внутреннего gRPC (приёмка Task 13, Ruling 6): dev — plaintext + service-token,
+// PROD (DEAL_MTLS_ENABLED=1) — mTLS. Пути/пароли не логируются (Ruling 13).
+app.Logger.LogInformation(
+ "Транспорт внутреннего gRPC: {Transport}",
+ mtlsOptions.Enabled ? "mTLS (DEAL_MTLS_ENABLED=1, сертификаты из DEAL_MTLS_*)" : "plaintext + service-token (dev)");
+
+if (forwardedHeadersConfig.Enabled)
+{
+ // Прокси-заголовки (план Task 12; замечание ревью T4/T11): X-Forwarded-For/X-Forwarded-Proto доверяются
+ // только клиентам из ForwardedHeaders:KnownProxies/KnownNetworks (конфиг; appsettings — loopback для dev).
+ // Middleware — ПЕРВЫЙ в конвейере: RemoteIpAddress/Scheme читают слои ниже (CORS, Session/OperatorSession —
+ // audit-IP эндпоинтов, RateLimiter — ключи по IP, LoginAttemptGuard). Без него за Caddy (compose-prod,
+ // Task 14) RemoteIpAddress всех запросов = IP Caddy, и audit-IP + rate-limit-по-IP схлопываются в один бакет.
+ app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
+}
+
+// Access-лог HTTP (Ruling 7, Task 14): первый в конвейере (после ForwardedHeaders) — длительность
+// и статус всего пути обработки. gRPC-ингресс (Content-Type application/grpc) middleware пропускает —
+// его логирует интерцептор RpcCallLoggingInterceptor (см. HttpAccessLogMiddleware).
+app.UseMiddleware();
+
+app.UseCors(corsPolicyName);
+app.UseMiddleware();
+app.UseMiddleware();
+if (rateLimitOptions.Enabled)
+{
+ // Порядок middleware — Ruling 5: Session → Operator → RateLimiter (политика "api" ключует по
+ // CurrentUser.TenantId либо IP анонима; сессии уже разрешены). При Enabled=false лимитер не
+ // регистрируется (dev-прогон не режет curl-приёмки; OriginGuard Task 12 встанет после).
+ app.UseRateLimiter();
+}
+
+// Origin-проверка мутаций /api (план Task 12, Ruling 10(2)): порядок Ruling 5 — Session → Operator →
+// RateLimiter → OriginGuard (сессии разрешены, 429 важнее 403). Проверяются не-GET/HEAD/OPTIONS запросы
+// с заголовком Origin: Origin == «свой» origin (схема + Host; за Caddy — https из X-Forwarded-Proto)
+// либо входит в Security:AllowedOrigins; иначе 403 {detail}. Без Origin (curl/сервер-сервер) пропускаются;
+// SameSite=Lax куки остаётся первым рубежом CSRF (фиксируется в техдок §10, Task 16).
+app.UseMiddleware();
+
+app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" }));
+app.MapAuthEndpoints();
+app.MapOperatorAuthEndpoints();
+app.MapOperatorAuditEndpoints();
+// Операторская аналитика (план этапа 10, T3): read-only сводка/расход токенов/лента действий.
+app.MapOperatorAnalyticsEndpoints();
+app.MapOperatorInvitesEndpoints();
+app.MapOperatorTenantsEndpoints();
+// Операторские лимиты/health (план Task 10, Ruling 3/11): сводка и смена бюджета по тенанту (GET/PATCH
+// .../limit, аудит tenant_limit_changed) + health ядра/БД и сервисов ml/ai/telegram.
+app.MapOperatorLimitsEndpoints();
+app.MapOperatorHealthEndpoints();
+// Глобальные настройки оператора (ТЗ §4.1/§8.1): ключи приложения Telegram — чтение (маска) и смена.
+app.MapOperatorSettingsEndpoints();
+// Обслуживание (этап 12, пакет C): идемпотентная пакетная миграция схем всех тенантов реестра
+// (ограниченный параллелизм + логирование прогресса) — для SaaS с сотнями/тысячами схем.
+app.MapOperatorMaintenanceEndpoints();
+app.MapJoinEndpoint();
+app.MapSettingsEndpoints();
+app.MapAiCheckEndpoint();
+app.MapRatesEndpoints();
+app.MapMlEndpoints();
+app.MapFilterTesterEndpoints();
+app.MapContainersEndpoints();
+app.MapCardsEndpoints();
+app.MapCardDetailsEndpoints();
+app.MapStorageEndpoints();
+app.MapEventsEndpoint();
+app.MapAiSuggestEndpoints();
+app.MapPipelineEndpoints();
+// Эндпоинты /api/tg (план Task 14, Ruling 8): реальный контракт вкладки «Каналы» вместо boot-заглушки
+// (BootStubEndpoints удалён); qr-image — отдельным файлом.
+app.MapTelegramEndpoints();
+app.MapTelegramQrImageEndpoint();
+// Эндпоинты /api/discovery (план Task 19, Ruling 11): задачи поиска/кандидаты/чёрный список/лог/generate-keywords
+// (DiscoveryEndpoints, 1:1 api-map §3.8) — модуль Discovery зарегистрирован AddDiscoveryModule выше.
+app.MapDiscoveryEndpoints();
+// gRPC-ингресс и его health освобождены от HTTP-политик rate limiter (план Task 11, Ruling 5): лимит
+// входящего потока считает IngressRateLimitInterceptor по tenant-id из metadata (иначе общее окно на IP
+// telegram-service резало бы весь ингресс раньше интерцептора); health — инфраструктурный liveness.
+app.MapGrpcService().DisableRateLimiting();
+app.MapGrpcHealthChecksService().DisableRateLimiting();
+
+app.Run();
+
+// ── Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (явные Listen заменяют URL-биндинг) ──
+
+// Биндит основной HTTP-эндпоинт(ы) из ключа "urls" (--urls/ASPNETCORE_URLS/launchSettings): без явных
+// адресов используется фолбэк defaultHttpUrl (как дефолт ASP.NET Core). Схема https — сертификат из
+// стандартной конфигурации Kestrel (UseHttps без аргументов), как при URL-биндинге.
+static void BindMainHttpEndpoints(KestrelServerOptions kestrel, string? urlsConfig)
+{
+ List addresses = ParseHttpAddresses(urlsConfig);
+ if (addresses.Count == 0)
+ {
+ addresses.Add(new Uri(defaultHttpUrl));
+ }
+
+ foreach (Uri address in addresses)
+ {
+ // Dev-запуски используют loopback («localhost»/127.0.0.1) — ListenLocalhost; «0.0.0.0»/«*»/«+»/«::»
+ // (compose/host) — все интерфейсы. Иных хостов в конфигурации нет (URL-хосты не биндятся по имени).
+ bool isHttps = string.Equals(address.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
+ string host = address.Host.Trim('[', ']');
+ if (host is "0.0.0.0" or "*" or "+" or "::")
+ {
+ kestrel.Listen(IPAddress.Any, address.Port, listen =>
+ {
+ if (isHttps)
+ {
+ listen.UseHttps();
+ }
+ });
+ }
+ else if (isHttps)
+ {
+ kestrel.ListenLocalhost(address.Port, listen => listen.UseHttps());
+ }
+ else
+ {
+ kestrel.ListenLocalhost(address.Port);
+ }
+ }
+}
+
+// Разбирает адреса конфигурации "urls" (http/https, разделитель «;»); невалидные/чужие схемы пропускаются.
+static List ParseHttpAddresses(string? urlsConfig)
+{
+ var addresses = new List();
+ if (string.IsNullOrWhiteSpace(urlsConfig))
+ {
+ return addresses;
+ }
+
+ foreach (string part in urlsConfig.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ if (Uri.TryCreate(part, UriKind.Absolute, out Uri? uri)
+ && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
+ {
+ addresses.Add(uri);
+ }
+ }
+
+ return addresses;
+}
+
+// Парсит порт из env-строки; пустое/нечисловое значение — null (фолбэк дефолта).
+static int? ParsePort(string? rawValue)
+ => int.TryParse(rawValue, out int parsedPort) ? parsedPort : null;
+
+// Дефолт-бюджет нового тенанта из env DEAL_DEFAULT_AI_BUDGET (Ruling 3, Task 8): нечисловое/неположительное
+// значение (пустая переменная, опечатка) — константа модуля TokenBudgetDefaults.DefaultBudgetTokens. Период
+// всегда month — оператор меняет бюджет/период позже через PATCH лимита (Task 10).
+long ResolveDefaultAiBudget(IConfiguration configuration)
+{
+ string? rawValue = configuration[defaultAiBudgetEnvKey];
+ return long.TryParse(rawValue, out long parsedBudget) && parsedBudget > 0
+ ? parsedBudget
+ : TokenBudgetDefaults.DefaultBudgetTokens;
+}
+
+public partial class Program
+{
+ ///
+ /// Строит опции UseForwardedHeaders из ForwardedHeadersConfig (план Task 12; замечание ревью
+ /// T4/T11): обрабатываются X-Forwarded-For/X-Forwarded-Proto ровно одного доверенного hop'а. Списки
+ /// доверия — строго из конфига KnownProxies/KnownNetworks (appsettings-дефолт — loopback); невалидный
+ /// IP/CIDR — InvalidOperationException (fail-fast: опечатка в настройке доверия не должна молча
+ /// отключать обработку). Публичный: unit-тесты опций (ForwardedHeadersHttpTests).
+ ///
+ /// Секция ForwardedHeaders конфигурации.
+ /// Опции для app.UseForwardedHeaders.
+ public static ForwardedHeadersOptions BuildForwardedHeadersOptions(ForwardedHeadersConfig config)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ var options = new ForwardedHeadersOptions
+ {
+ // Адрес клиента и схема (Origin-проверка «своего» origin за Caddy требует https из
+ // X-Forwarded-Proto). X-Forwarded-Host не нужен: Caddy транслирует Host без перезаписи.
+ ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
+ // Между клиентом и core ровно один прокси (Caddy): читаем ближайший к нам элемент цепочки.
+ ForwardLimit = 1,
+ };
+ // Доверие — только явный конфиг + loopback-фолбэк ниже: очищаем дефолты конструктора, чтобы
+ // перечисление в KnownProxies/KnownIPNetworks было единственным источником истины.
+ options.KnownProxies.Clear();
+ options.KnownIPNetworks.Clear();
+ foreach (string proxy in config.KnownProxies)
+ {
+ if (!IPAddress.TryParse(proxy, out IPAddress? address))
+ {
+ throw new InvalidOperationException(
+ $"Невалидный IP доверенного прокси в ForwardedHeaders:KnownProxies: \"{proxy}\".");
+ }
+
+ options.KnownProxies.Add(address);
+ }
+
+ foreach (string network in config.KnownNetworks)
+ {
+ if (!TryParseCidr(network, out System.Net.IPNetwork parsedNetwork))
+ {
+ throw new InvalidOperationException(
+ $"Невалидная подсеть доверенного прокси в ForwardedHeaders:KnownNetworks: \"{network}\" "
+ + "(ожидается CIDR, напр. 172.16.0.0/12).");
+ }
+
+ options.KnownIPNetworks.Add(parsedNetwork);
+ }
+
+ if (options.KnownProxies.Count == 0 && options.KnownIPNetworks.Count == 0)
+ {
+ // Пустые списки KnownProxies/KnownIPNetworks у ForwardedHeadersMiddleware означают «доверять
+ // ЛЮБОМУ клиенту» (спуфинг XFF) — пустоту до middleware не допускаем. Дефолт без явного
+ // конфига — loopback (dev-прокси на хосте: vite/локальный Caddy, см. appsettings); PROD
+ // перечисляет Caddy адресами/подсетями в конфиге.
+ options.KnownProxies.Add(IPAddress.Loopback);
+ options.KnownProxies.Add(IPAddress.IPv6Loopback);
+ }
+
+ return options;
+ }
+
+ // Разбирает CIDR-запись подсети ("127.0.0.0/8", "2001:db8::/32"); без префикса — вся
+ // подсеть одного адреса (IPv4 — /32, IPv6 — /128).
+ // value: Строка конфига (ForwardedHeaders:KnownNetworks).
+ // network: Разобранная подсеть при успехе.
+ // Возвращает: true — запись валидна.
+ private static bool TryParseCidr(string value, out System.Net.IPNetwork network)
+ {
+ network = default;
+ int slashIndex = value.IndexOf('/');
+ string addressPart = slashIndex >= 0 ? value[..slashIndex] : value;
+ if (!IPAddress.TryParse(addressPart, out IPAddress? address))
+ {
+ return false;
+ }
+
+ int maxPrefixLength = address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128;
+ int prefixLength = maxPrefixLength;
+ if (slashIndex >= 0)
+ {
+ string lengthPart = value[(slashIndex + 1)..];
+ if (!int.TryParse(lengthPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength)
+ {
+ return false;
+ }
+ }
+
+ network = new System.Net.IPNetwork(address, prefixLength);
+ return true;
+ }
+}
diff --git a/src/core/Deal.Api/RatesRefreshScheduler.cs b/src/core/Deal.Api/RatesRefreshScheduler.cs
index 3c182b9..9abdb3d 100644
--- a/src/core/Deal.Api/RatesRefreshScheduler.cs
+++ b/src/core/Deal.Api/RatesRefreshScheduler.cs
@@ -1,4 +1,7 @@
-using Deal.Modules.Settings.Application;
+using Deal.Modules.Settings.Application.Abstractions;
+using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
namespace Deal.Api;
diff --git a/src/core/Deal.Api/Telegram/TelegramIngressService.cs b/src/core/Deal.Api/Telegram/TelegramIngressService.cs
index 7d356b5..73ec94c 100644
--- a/src/core/Deal.Api/Telegram/TelegramIngressService.cs
+++ b/src/core/Deal.Api/Telegram/TelegramIngressService.cs
@@ -1,417 +1,424 @@
-using System.Text.Json;
-using Deal.Api.Events;
-using Deal.Contracts.Integrations.Models;
-using Deal.Grpc.Telegram;
-using Deal.Modules.Pipeline.Application;
-using Deal.Modules.Pipeline.Application.Models;
-using Deal.Modules.Settings.Application;
-using Deal.Modules.Settings.Application.Models;
-using Deal.Modules.Telegram.Application;
-using Deal.Modules.Tenants.Application;
-using Deal.Modules.Tenants.Application.Models;
-using Deal.SharedKernel.Tenants;
-using Grpc.Core;
-
-namespace Deal.Api.Telegram;
-
-///
-/// gRPC-сервер входящего потока telegram-service → ядро (план Task 12, L361–377; Ruling 1/7).
-///
-/// Реализация серверной стороны Deal.Grpc.Telegram.IngressService (telegram.proto, L380–395):
-/// PushMessage — новое/догоняющее сообщение мониторящегося диалога в очередь пайплайна
-/// (, тот же контракт, что приём сообщений пайплайна) в схеме тенанта
-/// + превью (DialogsService.SavePreview: TgMessages + «последнее сообщение» каталога, Ruling 7);
-/// SyncDialogs — применение каталога диалогов (DialogsService.SyncFromTelegram) и ответ со списком
-/// monitored id (зеркало сервиса); ReportStatus — статус аккаунта в KV (tgStatus/tgAccount) + SSE
-/// system_status/тосты на переходах фаз.
-///
-///
-/// Tenant-id берётся ТОЛЬКО из gRPC-metadata (полю в теле не доверяем — Ruling 1), принадлежность
-/// подтверждается реестром тенантов (public.tenants), затем для работы открывается собственный scope
-/// с ITenantContext.SetTenant (эталон PipelineWorkerScheduler, L169–213): tenant-scoped адаптеры
-/// (PipelineStore/SettingsStore) строятся от схемы тенанта. Неизвестный тенант/сбой схемы — RPC не падает:
-/// ответ не-принято (accepted=false / ok=false, план Task 12) + лог аудита (Ruling 13); недоступный сервис
-/// догоняет упущенное realtime-sweep (контракт README).
-///
-/// Полная синхронизация каталога (применение entries к таблице Dialogs, ответ = список monitored id) —
-/// модуль Deal.Modules.Telegram (план Task 13): DialogsService.SyncFromTelegram (upsert/удаление, авто-
-/// мониторинг новых по autoMonitorNew), превью сообщений — DialogsService.SavePreview (PushMessage).
-///
-///
-public sealed class TelegramIngressService(
- IServiceScopeFactory scopeFactory,
- SseBroker broker,
- ILogger logger) : IngressService.IngressServiceBase
-{
- ///
- /// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1).
- ///
- public const string TenantIdMetadataKey = "tenant-id";
-
- // Тип SSE-события статуса Telegram (фронт по нему перечитывает GET /api/tg/status, Ruling 7).
- private const string SystemStatusEventType = "system_status";
-
- // Тип SSE-события тоста (Ruling 5; api.js L79 слушает 'toast').
- private const string ToastEventType = "toast";
-
- // Текст тоста подключения (Ruling 7, 1:1 с прототипом).
- private const string ConnectedToastText = "Telegram подключён, сессия сохранена";
-
- // Текст тоста отключения (Ruling 7, 1:1 с прототипом).
- private const string DisconnectedToastText = "Telegram отключён";
-
- // Иконка тоста подключения (из набора Icon.vue фронта).
- private const string ConnectedToastIcon = "send";
-
- // Иконка тоста отключения (из набора Icon.vue фронта).
- private const string DisconnectedToastIcon = "logout";
-
- // Деталь отказа: metadata tenant-id отсутствует (UNAUTHENTICATED, README src/contracts).
- private const string MissingTenantIdDetail = "tenant-id отсутствует в metadata";
-
- // Опции JSON KV-статуса: camelCase (1:1 с wire-именами) + терпимость регистра при чтении.
- private static readonly JsonSerializerOptions StatusJsonOptions = new()
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- PropertyNameCaseInsensitive = true,
- };
-
- ///
- /// PushMessage — сообщение диалога в очередь пайплайна тенанта + превью (PushMessageRequest, Ruling 7).
- ///
- /// Дубль dialog_id+msg_id уже в очереди — duplicate=true, очередь не растёт (гвард
- /// PipelineIngestService). Пустой текст/диалог — no-op приёма (accepted=false, контракт proto).
- /// После постановки в очередь пишется превью (DialogsService.SavePreview: строка TgMessages
- /// «m_<dialog>_<msg>» + «последнее сообщение» каталога — 1:1 _on_message python L270–274);
- /// сбой превью не влияет на приём (accepted определён очередью, лог дебага).
- /// Неизвестный тенант или сбой схемы/БД — не-принято (accepted=false) без исключения RPC.
- /// Сообщение из потока telegram-service.
- /// Контекст вызова (metadata tenant-id + service-token).
- /// accepted — сообщение принято (либо дубль), duplicate — уже было в очереди.
- public override async Task PushMessage(PushMessageRequest request, ServerCallContext context)
- {
- TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
- if (tenant is null)
- {
- return new PushMessageReply();
- }
-
- await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope();
- ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService();
- try
- {
- // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
- tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
- PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService();
-
- PipelineIngestResultDto result = await ingest.EnqueueAsync(
- new QueuedMessage
- {
- DialogId = request.DialogId,
- ChannelName = request.ChannelName,
- ChannelHandle = request.ChannelHandle,
- ChannelHue = request.ChannelHue,
- MsgId = request.HasMsgId ? request.MsgId : null,
- Text = request.Text,
- MsgAtMs = request.HasMsgAt ? request.MsgAt : null,
- },
- context.CancellationToken).ConfigureAwait(false);
-
- await SavePreviewSafelyAsync(tenantScope, tenant, request, context.CancellationToken).ConfigureAwait(false);
-
- logger.LogInformation(
- "Аудит: PushMessage {TenantId} диалог {DialogId} msg {MsgId} → {Outcome}",
- tenant.Id,
- request.DialogId,
- request.HasMsgId ? request.MsgId.ToString() : "-",
- result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued");
-
- return new PushMessageReply
- {
- Accepted = result.Id is not null || result.Duplicate,
- Duplicate = result.Duplicate,
- };
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception exception)
- {
- // Сбой схемы/БД тенанта (напр. схема ещё не провижинена): RPC не падает — reply not-accepted
- // (план Task 12), упущенное сообщение при необходимости догонит realtime-sweep сервиса.
- logger.LogWarning(exception, "Аудит: PushMessage {TenantId} → не принято (сбой схемы/БД)", tenant.Id);
- return new PushMessageReply();
- }
- finally
- {
- tenantContext.Reset();
- }
- }
-
- ///
- /// SyncDialogs — синхронизация каталога диалогов аккаунта (Ruling 7, L386–390).
- ///
- ///
- /// Модуль Deal.Modules.Telegram (план Task 13) применяет entries к таблице Dialogs
- /// (DialogsService.SyncFromTelegram: upsert + удаление отсутствующих; авто-мониторинг новых — по
- /// настройке autoMonitorNew). Ответ несёт актуальный список monitored id — по нему telegram-service
- /// держит своё зеркало мониторинга в памяти (обновляется ответом SyncDialogs и командой SetMonitor,
- /// Ruling 7) и фильтрует события realtime.
- ///
- /// Актуальный каталог диалогов (entries).
- /// Контекст вызова.
- /// monitored_ids — диалоги с включённым мониторингом после применения каталога.
- public override async Task SyncDialogs(SyncDialogsRequest request, ServerCallContext context)
- {
- TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
- if (tenant is null)
- {
- return new SyncDialogsReply();
- }
-
- await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope();
- ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService();
- try
- {
- tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
- DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService();
-
- List entries = new(request.Entries.Count);
- foreach (DialogEntry entry in request.Entries)
- {
- entries.Add(new TelegramDialogEntryDto(entry.Id, entry.Name, entry.Username, entry.Kind, entry.Hue));
- }
-
- int synced = await dialogs.SyncFromTelegramAsync(entries, context.CancellationToken).ConfigureAwait(false);
- IReadOnlyCollection monitoredIds =
- await dialogs.ListMonitoredIdsAsync(context.CancellationToken).ConfigureAwait(false);
-
- logger.LogInformation(
- "Аудит: SyncDialogs {TenantId}: каталог {Count} → применено {Synced}, monitored {Monitored}",
- tenant.Id,
- request.Entries.Count,
- synced,
- monitoredIds.Count);
-
- var reply = new SyncDialogsReply();
- reply.MonitoredIds.AddRange(monitoredIds);
- return reply;
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception exception)
- {
- logger.LogWarning(exception, "Аудит: SyncDialogs {TenantId} → каталог не применён (сбой схемы/БД)", tenant.Id);
- return new SyncDialogsReply();
- }
- finally
- {
- tenantContext.Reset();
- }
- }
-
- ///
- /// ReportStatus — статус аккаунта в KV + SSE system_status/тосты на переходах фаз (Ruling 7).
- ///
- /// KV tgStatus (снимок без account) и tgAccount (JSON-строка) пишутся в схему тенанта;
- /// system_status публикуется на каждый репорт (фронт перечитывает /api/tg/status), тосты — только на
- /// переходы connected: false→true «Telegram подключён, сессия сохранена», true→false «Telegram отключён»
- /// (сервис шлёт статус по событию и heartbeat'ом — без гарда переходов тосты дублировались бы).
- /// Неизвестный тенант/сбой схемы — ok=false без исключения RPC (план Task 12).
- /// Статус аккаунта из _publish_status прототипа.
- /// Контекст вызова.
- /// ok — статус принят и сохранён.
- public override async Task ReportStatus(ReportStatusRequest request, ServerCallContext context)
- {
- TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
- if (tenant is null)
- {
- return new ReportStatusReply();
- }
-
- await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope();
- ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService();
- try
- {
- tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
- ISettingsStore settings = tenantScope.ServiceProvider.GetRequiredService();
-
- TgReportedStatus current = ToReportedStatus(request);
- TgReportedStatus? previous = await ReadPreviousStatusAsync(settings, context.CancellationToken).ConfigureAwait(false);
-
- // SSE до записи KV: канал тенанта обновляется и при сбое записи (следующий репорт перепишет KV).
- PublishStatusEvents(tenant.Id, previous, current);
-
- await settings.SetAsync(SettingsKeys.TgStatus, ToJson(current), context.CancellationToken).ConfigureAwait(false);
- await settings.SetAsync(SettingsKeys.TgAccount, JsonSerializer.Serialize(request.Account), context.CancellationToken).ConfigureAwait(false);
-
- logger.LogInformation(
- "Аудит: ReportStatus {TenantId} → фаза {Phase}, connected {Connected}",
- tenant.Id,
- request.Phase,
- request.Connected);
- return new ReportStatusReply { Ok = true };
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception exception)
- {
- logger.LogWarning(exception, "Аудит: ReportStatus {TenantId} → не сохранён (сбой схемы/БД)", tenant.Id);
- return new ReportStatusReply();
- }
- finally
- {
- tenantContext.Reset();
- }
- }
-
- // Разрешает тенанта запроса: metadata tenant-id → реестр public.tenants.
- // Отсутствующий/пустой tenant-id — RPC-отказ UNAUTHENTICATED (README: tenant-id обязателен).
- // Id не Guid либо записи нет в реестре — неизвестный тенант: лог аудита и null (RPC отвечает не-принято,
- // план Task 12: «для несуществующего тенанта не падает»).
- // context: Контекст вызова.
- // Возвращает: Запись тенанта реестра либо null (тенант неизвестен).
- private async Task ResolveTenantAsync(ServerCallContext context)
- {
- string tenantId = RequireTenantIdMetadata(context);
- if (!Guid.TryParse(tenantId, out Guid tenantGuid))
- {
- logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (id не Guid)", context.Method, tenantId);
- return null;
- }
-
- await using AsyncServiceScope registryScope = scopeFactory.CreateAsyncScope();
- ITenantRepository repository = registryScope.ServiceProvider.GetRequiredService();
- TenantRecordDto? tenant = await repository.FindByIdAsync(tenantGuid, context.CancellationToken).ConfigureAwait(false);
- if (tenant is null)
- {
- logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (нет в реестре)", context.Method, tenantId);
- }
-
- return tenant;
- }
-
- // Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, README).
- // context: Контекст вызова.
- // Возвращает: Значение tenant-id.
- private static string RequireTenantIdMetadata(ServerCallContext context)
- {
- string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey);
- if (string.IsNullOrWhiteSpace(tenantId))
- {
- throw new RpcException(new Status(StatusCode.Unauthenticated, MissingTenantIdDetail));
- }
-
- return tenantId;
- }
-
- // Пишет превью принятого сообщения (TgMessages + «последнее сообщение» каталога) без влияния на приём.
- // Ruling 7: PushMessage → EnqueueAsync + превью. Сбой превью (нет таблиц/строки каталога и т.п.)
- // не роняет RPC и не меняет accepted — очередь уже записана, упущенное догонит realtime-sweep (как
- // python: обновление last_text после enqueue в том же обработчике, ошибка не отменяет приём).
- // tenantScope: Scope тенанта (TenantDbContext построен на схеме тенанта).
- // tenant: Тенант канала (для лога аудита).
- // request: Сообщение PushMessage.
- // ct: Токен отмены.
- private async Task SavePreviewSafelyAsync(
- AsyncServiceScope tenantScope, TenantRecordDto tenant, PushMessageRequest request, CancellationToken ct)
- {
- try
- {
- DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService();
- DateTimeOffset? msgAt = request.HasMsgAt ? DateTimeOffset.FromUnixTimeMilliseconds(request.MsgAt) : null;
- await dialogs.SavePreviewAsync(
- request.DialogId,
- request.HasMsgId ? request.MsgId : null,
- request.Text,
- msgAt,
- ct).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception exception)
- {
- // Превью — вторичная запись: приём не затронут (лог дебага, не ошибка RPC).
- logger.LogDebug(exception, "PushMessage {TenantId}: превью не сохранено (приём не затронут)", tenant.Id);
- }
- }
-
- // Публикует SSE system_status (каждый репорт) и тосты на переходах connected.
- // tenantId: Тенант канала (реестровый Guid).
- // previous: Предыдущий снимок из KV (null — первый репорт).
- // current: Текущий снимок репорта.
- private void PublishStatusEvents(Guid tenantId, TgReportedStatus? previous, TgReportedStatus current)
- {
- broker.Publish(tenantId, SystemStatusEventType, current);
- if (previous is null)
- {
- // Первый репорт после старта сервиса: переходов нет, статус фронт получит по system_status.
- return;
- }
-
- if (!previous.Connected && current.Connected)
- {
- PublishToast(tenantId, ConnectedToastText, ConnectedToastIcon);
- }
- else if (previous.Connected && !current.Connected)
- {
- PublishToast(tenantId, DisconnectedToastText, DisconnectedToastIcon);
- }
- }
-
- // Публикует SSE-тост в канал тенанта (без подписчиков — no-op, Ruling 5).
- // tenantId: Тенант-получатель.
- // text: Текст тоста.
- // icon: Иконка тоста (набор Icon.vue фронта).
- private void PublishToast(Guid tenantId, string text, string icon)
- {
- broker.Publish(tenantId, ToastEventType, new { text, icon });
- }
-
- // Снимок предыдущего статуса из KV tgStatus (нет записи/битый JSON — null).
- // settings: KV-хранилище настроек схемы тенанта.
- // ct: Токен отмены.
- // Возвращает: Предыдущий снимок либо null.
- private async Task ReadPreviousStatusAsync(ISettingsStore settings, CancellationToken ct)
- {
- SettingValue? stored = await settings.GetAsync(SettingsKeys.TgStatus, ct).ConfigureAwait(false);
- if (stored is null)
- {
- return null;
- }
-
- try
- {
- return JsonSerializer.Deserialize(stored.ValueJson, StatusJsonOptions);
- }
- catch (JsonException exception)
- {
- logger.LogWarning(exception, "Аудит: KV tgStatus повреждён — переходы фаз не определяются");
- return null;
- }
- }
-
- // Маппит запрос ReportStatus в снимок KV (account живёт отдельным ключом tgAccount).
- // request: Запрос ReportStatus.
- // Возвращает: Снимок статуса.
- private static TgReportedStatus ToReportedStatus(ReportStatusRequest request) => new()
- {
- Phase = request.Phase,
- Connected = request.Connected,
- Listener = request.Listener,
- Error = request.HasError ? request.Error : null,
- QrUrl = request.HasQrUrl ? request.QrUrl : null,
- };
-
- // Сериализует снимок в JSON (camelCase, конвенция value_json).
- // status: Снимок статуса.
- // Возвращает: JSON-строка.
- private static string ToJson(TgReportedStatus status) => JsonSerializer.Serialize(status, StatusJsonOptions);
-}
+using System.Text.Json;
+using Deal.Api.Events;
+using Deal.Contracts.Integrations.Models;
+using Deal.Grpc.Telegram;
+using Deal.Modules.Pipeline.Application.Abstractions;
+using Deal.Modules.Pipeline.Application.Models;
+using Deal.Modules.Pipeline.Application.Registrars;
+using Deal.Modules.Pipeline.Application.Services;
+using Deal.Modules.Settings.Application.Abstractions;
+using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
+using Deal.Modules.Telegram.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
+using Deal.SharedKernel.Tenants;
+using Grpc.Core;
+
+namespace Deal.Api.Telegram;
+
+///
+/// gRPC-сервер входящего потока telegram-service → ядро (план Task 12, L361–377; Ruling 1/7).
+///
+/// Реализация серверной стороны Deal.Grpc.Telegram.IngressService (telegram.proto, L380–395):
+/// PushMessage — новое/догоняющее сообщение мониторящегося диалога в очередь пайплайна
+/// (, тот же контракт, что приём сообщений пайплайна) в схеме тенанта
+/// + превью (DialogsService.SavePreview: TgMessages + «последнее сообщение» каталога, Ruling 7);
+/// SyncDialogs — применение каталога диалогов (DialogsService.SyncFromTelegram) и ответ со списком
+/// monitored id (зеркало сервиса); ReportStatus — статус аккаунта в KV (tgStatus/tgAccount) + SSE
+/// system_status/тосты на переходах фаз.
+///
+///
+/// Tenant-id берётся ТОЛЬКО из gRPC-metadata (полю в теле не доверяем — Ruling 1), принадлежность
+/// подтверждается реестром тенантов (public.tenants), затем для работы открывается собственный scope
+/// с ITenantContext.SetTenant (эталон PipelineWorkerScheduler, L169–213): tenant-scoped адаптеры
+/// (PipelineStore/SettingsStore) строятся от схемы тенанта. Неизвестный тенант/сбой схемы — RPC не падает:
+/// ответ не-принято (accepted=false / ok=false, план Task 12) + лог аудита (Ruling 13); недоступный сервис
+/// догоняет упущенное realtime-sweep (контракт README).
+///
+/// Полная синхронизация каталога (применение entries к таблице Dialogs, ответ = список monitored id) —
+/// модуль Deal.Modules.Telegram (план Task 13): DialogsService.SyncFromTelegram (upsert/удаление, авто-
+/// мониторинг новых по autoMonitorNew), превью сообщений — DialogsService.SavePreview (PushMessage).
+///
+///
+public sealed class TelegramIngressService(
+ IServiceScopeFactory scopeFactory,
+ SseBroker broker,
+ ILogger logger) : IngressService.IngressServiceBase
+{
+ ///
+ /// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1).
+ ///
+ public const string TenantIdMetadataKey = "tenant-id";
+
+ // Тип SSE-события статуса Telegram (фронт по нему перечитывает GET /api/tg/status, Ruling 7).
+ private const string SystemStatusEventType = "system_status";
+
+ // Тип SSE-события тоста (Ruling 5; api.js L79 слушает 'toast').
+ private const string ToastEventType = "toast";
+
+ // Текст тоста подключения (Ruling 7, 1:1 с прототипом).
+ private const string ConnectedToastText = "Telegram подключён, сессия сохранена";
+
+ // Текст тоста отключения (Ruling 7, 1:1 с прототипом).
+ private const string DisconnectedToastText = "Telegram отключён";
+
+ // Иконка тоста подключения (из набора Icon.vue фронта).
+ private const string ConnectedToastIcon = "send";
+
+ // Иконка тоста отключения (из набора Icon.vue фронта).
+ private const string DisconnectedToastIcon = "logout";
+
+ // Деталь отказа: metadata tenant-id отсутствует (UNAUTHENTICATED, README src/contracts).
+ private const string MissingTenantIdDetail = "tenant-id отсутствует в metadata";
+
+ // Опции JSON KV-статуса: camelCase (1:1 с wire-именами) + терпимость регистра при чтении.
+ private static readonly JsonSerializerOptions StatusJsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ };
+
+ ///
+ /// PushMessage — сообщение диалога в очередь пайплайна тенанта + превью (PushMessageRequest, Ruling 7).
+ ///
+ /// Дубль dialog_id+msg_id уже в очереди — duplicate=true, очередь не растёт (гвард
+ /// PipelineIngestService). Пустой текст/диалог — no-op приёма (accepted=false, контракт proto).
+ /// После постановки в очередь пишется превью (DialogsService.SavePreview: строка TgMessages
+ /// «m_<dialog>_<msg>» + «последнее сообщение» каталога — 1:1 _on_message python L270–274);
+ /// сбой превью не влияет на приём (accepted определён очередью, лог дебага).
+ /// Неизвестный тенант или сбой схемы/БД — не-принято (accepted=false) без исключения RPC.
+ /// Сообщение из потока telegram-service.
+ /// Контекст вызова (metadata tenant-id + service-token).
+ /// accepted — сообщение принято (либо дубль), duplicate — уже было в очереди.
+ public override async Task PushMessage(PushMessageRequest request, ServerCallContext context)
+ {
+ TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
+ if (tenant is null)
+ {
+ return new PushMessageReply();
+ }
+
+ await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope();
+ ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService();
+ try
+ {
+ // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
+ tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
+ PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService();
+
+ PipelineIngestResultDto result = await ingest.EnqueueAsync(
+ new QueuedMessage
+ {
+ DialogId = request.DialogId,
+ ChannelName = request.ChannelName,
+ ChannelHandle = request.ChannelHandle,
+ ChannelHue = request.ChannelHue,
+ MsgId = request.HasMsgId ? request.MsgId : null,
+ Text = request.Text,
+ MsgAtMs = request.HasMsgAt ? request.MsgAt : null,
+ },
+ context.CancellationToken).ConfigureAwait(false);
+
+ await SavePreviewSafelyAsync(tenantScope, tenant, request, context.CancellationToken).ConfigureAwait(false);
+
+ logger.LogInformation(
+ "Аудит: PushMessage {TenantId} диалог {DialogId} msg {MsgId} → {Outcome}",
+ tenant.Id,
+ request.DialogId,
+ request.HasMsgId ? request.MsgId.ToString() : "-",
+ result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued");
+
+ return new PushMessageReply
+ {
+ Accepted = result.Id is not null || result.Duplicate,
+ Duplicate = result.Duplicate,
+ };
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ // Сбой схемы/БД тенанта (напр. схема ещё не провижинена): RPC не падает — reply not-accepted
+ // (план Task 12), упущенное сообщение при необходимости догонит realtime-sweep сервиса.
+ logger.LogWarning(exception, "Аудит: PushMessage {TenantId} → не принято (сбой схемы/БД)", tenant.Id);
+ return new PushMessageReply();
+ }
+ finally
+ {
+ tenantContext.Reset();
+ }
+ }
+
+ ///
+ /// SyncDialogs — синхронизация каталога диалогов аккаунта (Ruling 7, L386–390).
+ ///
+ ///
+ /// Модуль Deal.Modules.Telegram (план Task 13) применяет entries к таблице Dialogs
+ /// (DialogsService.SyncFromTelegram: upsert + удаление отсутствующих; авто-мониторинг новых — по
+ /// настройке autoMonitorNew). Ответ несёт актуальный список monitored id — по нему telegram-service
+ /// держит своё зеркало мониторинга в памяти (обновляется ответом SyncDialogs и командой SetMonitor,
+ /// Ruling 7) и фильтрует события realtime.
+ ///
+ /// Актуальный каталог диалогов (entries).
+ /// Контекст вызова.
+ /// monitored_ids — диалоги с включённым мониторингом после применения каталога.
+ public override async Task SyncDialogs(SyncDialogsRequest request, ServerCallContext context)
+ {
+ TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
+ if (tenant is null)
+ {
+ return new SyncDialogsReply();
+ }
+
+ await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope();
+ ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService();
+ try
+ {
+ tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
+ DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService();
+
+ List entries = new(request.Entries.Count);
+ foreach (DialogEntry entry in request.Entries)
+ {
+ entries.Add(new TelegramDialogEntryDto(entry.Id, entry.Name, entry.Username, entry.Kind, entry.Hue));
+ }
+
+ int synced = await dialogs.SyncFromTelegramAsync(entries, context.CancellationToken).ConfigureAwait(false);
+ IReadOnlyCollection monitoredIds =
+ await dialogs.ListMonitoredIdsAsync(context.CancellationToken).ConfigureAwait(false);
+
+ logger.LogInformation(
+ "Аудит: SyncDialogs {TenantId}: каталог {Count} → применено {Synced}, monitored {Monitored}",
+ tenant.Id,
+ request.Entries.Count,
+ synced,
+ monitoredIds.Count);
+
+ var reply = new SyncDialogsReply();
+ reply.MonitoredIds.AddRange(monitoredIds);
+ return reply;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ logger.LogWarning(exception, "Аудит: SyncDialogs {TenantId} → каталог не применён (сбой схемы/БД)", tenant.Id);
+ return new SyncDialogsReply();
+ }
+ finally
+ {
+ tenantContext.Reset();
+ }
+ }
+
+ ///
+ /// ReportStatus — статус аккаунта в KV + SSE system_status/тосты на переходах фаз (Ruling 7).
+ ///
+ /// KV tgStatus (снимок без account) и tgAccount (JSON-строка) пишутся в схему тенанта;
+ /// system_status публикуется на каждый репорт (фронт перечитывает /api/tg/status), тосты — только на
+ /// переходы connected: false→true «Telegram подключён, сессия сохранена», true→false «Telegram отключён»
+ /// (сервис шлёт статус по событию и heartbeat'ом — без гарда переходов тосты дублировались бы).
+ /// Неизвестный тенант/сбой схемы — ok=false без исключения RPC (план Task 12).
+ /// Статус аккаунта из _publish_status прототипа.
+ /// Контекст вызова.
+ /// ok — статус принят и сохранён.
+ public override async Task ReportStatus(ReportStatusRequest request, ServerCallContext context)
+ {
+ TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
+ if (tenant is null)
+ {
+ return new ReportStatusReply();
+ }
+
+ await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope();
+ ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService();
+ try
+ {
+ tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
+ ISettingsStore settings = tenantScope.ServiceProvider.GetRequiredService();
+
+ TgReportedStatus current = ToReportedStatus(request);
+ TgReportedStatus? previous = await ReadPreviousStatusAsync(settings, context.CancellationToken).ConfigureAwait(false);
+
+ // SSE до записи KV: канал тенанта обновляется и при сбое записи (следующий репорт перепишет KV).
+ PublishStatusEvents(tenant.Id, previous, current);
+
+ await settings.SetAsync(SettingsKeys.TgStatus, ToJson(current), context.CancellationToken).ConfigureAwait(false);
+ await settings.SetAsync(SettingsKeys.TgAccount, JsonSerializer.Serialize(request.Account), context.CancellationToken).ConfigureAwait(false);
+
+ logger.LogInformation(
+ "Аудит: ReportStatus {TenantId} → фаза {Phase}, connected {Connected}",
+ tenant.Id,
+ request.Phase,
+ request.Connected);
+ return new ReportStatusReply { Ok = true };
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ logger.LogWarning(exception, "Аудит: ReportStatus {TenantId} → не сохранён (сбой схемы/БД)", tenant.Id);
+ return new ReportStatusReply();
+ }
+ finally
+ {
+ tenantContext.Reset();
+ }
+ }
+
+ // Разрешает тенанта запроса: metadata tenant-id → реестр public.tenants.
+ // Отсутствующий/пустой tenant-id — RPC-отказ UNAUTHENTICATED (README: tenant-id обязателен).
+ // Id не Guid либо записи нет в реестре — неизвестный тенант: лог аудита и null (RPC отвечает не-принято,
+ // план Task 12: «для несуществующего тенанта не падает»).
+ // context: Контекст вызова.
+ // Возвращает: Запись тенанта реестра либо null (тенант неизвестен).
+ private async Task ResolveTenantAsync(ServerCallContext context)
+ {
+ string tenantId = RequireTenantIdMetadata(context);
+ if (!Guid.TryParse(tenantId, out Guid tenantGuid))
+ {
+ logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (id не Guid)", context.Method, tenantId);
+ return null;
+ }
+
+ await using AsyncServiceScope registryScope = scopeFactory.CreateAsyncScope();
+ ITenantRepository repository = registryScope.ServiceProvider.GetRequiredService();
+ TenantRecordDto? tenant = await repository.FindByIdAsync(tenantGuid, context.CancellationToken).ConfigureAwait(false);
+ if (tenant is null)
+ {
+ logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (нет в реестре)", context.Method, tenantId);
+ }
+
+ return tenant;
+ }
+
+ // Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, README).
+ // context: Контекст вызова.
+ // Возвращает: Значение tenant-id.
+ private static string RequireTenantIdMetadata(ServerCallContext context)
+ {
+ string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey);
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ throw new RpcException(new Status(StatusCode.Unauthenticated, MissingTenantIdDetail));
+ }
+
+ return tenantId;
+ }
+
+ // Пишет превью принятого сообщения (TgMessages + «последнее сообщение» каталога) без влияния на приём.
+ // Ruling 7: PushMessage → EnqueueAsync + превью. Сбой превью (нет таблиц/строки каталога и т.п.)
+ // не роняет RPC и не меняет accepted — очередь уже записана, упущенное догонит realtime-sweep (как
+ // python: обновление last_text после enqueue в том же обработчике, ошибка не отменяет приём).
+ // tenantScope: Scope тенанта (TenantDbContext построен на схеме тенанта).
+ // tenant: Тенант канала (для лога аудита).
+ // request: Сообщение PushMessage.
+ // ct: Токен отмены.
+ private async Task SavePreviewSafelyAsync(
+ AsyncServiceScope tenantScope, TenantRecordDto tenant, PushMessageRequest request, CancellationToken ct)
+ {
+ try
+ {
+ DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService();
+ DateTimeOffset? msgAt = request.HasMsgAt ? DateTimeOffset.FromUnixTimeMilliseconds(request.MsgAt) : null;
+ await dialogs.SavePreviewAsync(
+ request.DialogId,
+ request.HasMsgId ? request.MsgId : null,
+ request.Text,
+ msgAt,
+ ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ // Превью — вторичная запись: приём не затронут (лог дебага, не ошибка RPC).
+ logger.LogDebug(exception, "PushMessage {TenantId}: превью не сохранено (приём не затронут)", tenant.Id);
+ }
+ }
+
+ // Публикует SSE system_status (каждый репорт) и тосты на переходах connected.
+ // tenantId: Тенант канала (реестровый Guid).
+ // previous: Предыдущий снимок из KV (null — первый репорт).
+ // current: Текущий снимок репорта.
+ private void PublishStatusEvents(Guid tenantId, TgReportedStatus? previous, TgReportedStatus current)
+ {
+ broker.Publish(tenantId, SystemStatusEventType, current);
+ if (previous is null)
+ {
+ // Первый репорт после старта сервиса: переходов нет, статус фронт получит по system_status.
+ return;
+ }
+
+ if (!previous.Connected && current.Connected)
+ {
+ PublishToast(tenantId, ConnectedToastText, ConnectedToastIcon);
+ }
+ else if (previous.Connected && !current.Connected)
+ {
+ PublishToast(tenantId, DisconnectedToastText, DisconnectedToastIcon);
+ }
+ }
+
+ // Публикует SSE-тост в канал тенанта (без подписчиков — no-op, Ruling 5).
+ // tenantId: Тенант-получатель.
+ // text: Текст тоста.
+ // icon: Иконка тоста (набор Icon.vue фронта).
+ private void PublishToast(Guid tenantId, string text, string icon)
+ {
+ broker.Publish(tenantId, ToastEventType, new { text, icon });
+ }
+
+ // Снимок предыдущего статуса из KV tgStatus (нет записи/битый JSON — null).
+ // settings: KV-хранилище настроек схемы тенанта.
+ // ct: Токен отмены.
+ // Возвращает: Предыдущий снимок либо null.
+ private async Task ReadPreviousStatusAsync(ISettingsStore settings, CancellationToken ct)
+ {
+ SettingValue? stored = await settings.GetAsync(SettingsKeys.TgStatus, ct).ConfigureAwait(false);
+ if (stored is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize(stored.ValueJson, StatusJsonOptions);
+ }
+ catch (JsonException exception)
+ {
+ logger.LogWarning(exception, "Аудит: KV tgStatus повреждён — переходы фаз не определяются");
+ return null;
+ }
+ }
+
+ // Маппит запрос ReportStatus в снимок KV (account живёт отдельным ключом tgAccount).
+ // request: Запрос ReportStatus.
+ // Возвращает: Снимок статуса.
+ private static TgReportedStatus ToReportedStatus(ReportStatusRequest request) => new()
+ {
+ Phase = request.Phase,
+ Connected = request.Connected,
+ Listener = request.Listener,
+ Error = request.HasError ? request.Error : null,
+ QrUrl = request.HasQrUrl ? request.QrUrl : null,
+ };
+
+ // Сериализует снимок в JSON (camelCase, конвенция value_json).
+ // status: Снимок статуса.
+ // Возвращает: JSON-строка.
+ private static string ToJson(TgReportedStatus status) => JsonSerializer.Serialize(status, StatusJsonOptions);
+}
diff --git a/src/core/Deal.Api/Telegram/TelegramKeysService.cs b/src/core/Deal.Api/Telegram/TelegramKeysService.cs
index d5a88bf..d7632ea 100644
--- a/src/core/Deal.Api/Telegram/TelegramKeysService.cs
+++ b/src/core/Deal.Api/Telegram/TelegramKeysService.cs
@@ -1,6 +1,8 @@
using System.Text.Json;
-using Deal.Modules.Settings.Application;
+using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
namespace Deal.Api.Telegram;
diff --git a/src/core/Deal.Api/Telegram/TgStatusService.cs b/src/core/Deal.Api/Telegram/TgStatusService.cs
index ac34e80..f1f6821 100644
--- a/src/core/Deal.Api/Telegram/TgStatusService.cs
+++ b/src/core/Deal.Api/Telegram/TgStatusService.cs
@@ -1,108 +1,110 @@
-using System.Text.Json;
-using Deal.Contracts.Integrations;
-using Deal.Contracts.Integrations.Models;
-using Deal.Modules.Settings.Application;
-using Deal.Modules.Settings.Application.Models;
-using Deal.Modules.Telegram.Application;
-using Deal.Modules.Telegram.Application.Models;
-
-namespace Deal.Api.Telegram;
-
-///
-/// Сборка статуса вкладки Telegram — GET /api/tg/status (Ruling 8, api-map §4.9 L357–359).
-///
-///
-/// Форма 1:1 с status() python L103–119 в терминах этапа 6:
-///
-/// - live-поля (phase/connected/listener/error/qrUrl) — из гейта
-/// (живой telegram-service); сервис недоступен/сессии нет (RPC-отказ) → idle-форма (Ruling 8);
-/// - account — из KV tgAccount (источник истины — ReportStatus ингресса, Ruling 7);
-/// - monitored — count(Dialogs WHERE Monitor) ядра (DialogsService.ListMonitoredIds);
-/// - keysSet — оба глобальных ключа приложения заданы оператором (TelegramKeysService, Ruling 3, ТЗ §4.1/§8.1).
-///
-/// Scoped: зависимости живут на контекстах запроса (ISettingsStore — схема тенанта, IGlobalSettingsStore — public).
-///
-/// Порт-гейт telegram-service (живой статус аккаунта).
-/// Сервис каталога диалогов ядра (счётчик мониторящихся).
-/// KV-хранилище настроек тенанта (tgAccount).
-/// Глобальные ключи приложения Telegram (keysSet).
-public sealed class TgStatusService(
- ITelegramGateway gateway,
- DialogsService dialogs,
- ISettingsStore settings,
- TelegramKeysService keys)
-{
- // Фаза idle-формы (аккаунт не подключён/сервис недоступен — Ruling 8).
- private const string IdlePhase = "idle";
-
- // Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра.
- private static readonly JsonSerializerOptions KvJsonOptions = new()
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- PropertyNameCaseInsensitive = true,
- };
-
- ///
- /// Форма GET /api/tg/status текущего тенанта (поля §4.9).
- ///
- /// Токен отмены.
- /// Полный статус вкладки Telegram.
- public async Task GetAsync(CancellationToken ct)
- {
- TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false);
- string account = await ReadAccountAsync(ct).ConfigureAwait(false);
- int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count;
- TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false);
-
- return new TgStatusDto(
- Phase: live.Phase,
- Connected: live.Connected,
- Listener: live.Listener,
- Account: account,
- Monitored: monitored,
- KeysSet: snapshot.KeysSet,
- Error: live.Error,
- QrUrl: live.QrUrl);
- }
-
- // Живой статус из гейта; сбой (сервис недоступен/нет сессии) → idle-форма (Ruling 8).
- // ct: Токен отмены.
- // Возвращает: Статус гейта либо idle-поля.
- private async Task ReadLiveAsync(CancellationToken ct)
- {
- try
- {
- return await gateway.StatusAsync(ct).ConfigureAwait(false);
- }
- catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested)
- {
- // «Сервис недоступен → idle-форма» (Ruling 8): connected=false, live-поля пусты. Аккаунт/счётчики
- // ядро всё равно докладывает из своего KV/БД (ниже) — как python при отключённом клиенте.
- return new TelegramAccountStatusDto(IdlePhase, Connected: false, Listener: false, string.Empty, null, null);
- }
- }
-
- // Аккаунт «@username» из KV tgAccount (JSON-строка, пишет ReportStatus ингресса, Ruling 7).
- // ct: Токен отмены.
- // Возвращает: Аккаунт или пустая строка.
- private async Task ReadAccountAsync(CancellationToken ct)
- {
- SettingValue? row = await settings.GetAsync(SettingsKeys.TgAccount, ct).ConfigureAwait(false);
- if (row is null)
- {
- return string.Empty;
- }
-
- try
- {
- using JsonDocument document = JsonDocument.Parse(row.ValueJson);
- return document.RootElement.ValueKind == JsonValueKind.String
- ? document.RootElement.GetString() ?? string.Empty
- : string.Empty;
- }
- catch (JsonException)
- {
- return string.Empty;
- }
- }
-}
+using System.Text.Json;
+using Deal.Contracts.Integrations;
+using Deal.Contracts.Integrations.Models;
+using Deal.Modules.Settings.Application.Abstractions;
+using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
+using Deal.Modules.Telegram.Application;
+using Deal.Modules.Telegram.Application.Models;
+
+namespace Deal.Api.Telegram;
+
+///
+/// Сборка статуса вкладки Telegram — GET /api/tg/status (Ruling 8, api-map §4.9 L357–359).
+///
+///
+/// Форма 1:1 с status() python L103–119 в терминах этапа 6:
+///
+/// - live-поля (phase/connected/listener/error/qrUrl) — из гейта
+/// (живой telegram-service); сервис недоступен/сессии нет (RPC-отказ) → idle-форма (Ruling 8);
+/// - account — из KV tgAccount (источник истины — ReportStatus ингресса, Ruling 7);
+/// - monitored — count(Dialogs WHERE Monitor) ядра (DialogsService.ListMonitoredIds);
+/// - keysSet — оба глобальных ключа приложения заданы оператором (TelegramKeysService, Ruling 3, ТЗ §4.1/§8.1).
+///
+/// Scoped: зависимости живут на контекстах запроса (ISettingsStore — схема тенанта, IGlobalSettingsStore — public).
+///
+/// Порт-гейт telegram-service (живой статус аккаунта).
+/// Сервис каталога диалогов ядра (счётчик мониторящихся).
+/// KV-хранилище настроек тенанта (tgAccount).
+/// Глобальные ключи приложения Telegram (keysSet).
+public sealed class TgStatusService(
+ ITelegramGateway gateway,
+ DialogsService dialogs,
+ ISettingsStore settings,
+ TelegramKeysService keys)
+{
+ // Фаза idle-формы (аккаунт не подключён/сервис недоступен — Ruling 8).
+ private const string IdlePhase = "idle";
+
+ // Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра.
+ private static readonly JsonSerializerOptions KvJsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ };
+
+ ///
+ /// Форма GET /api/tg/status текущего тенанта (поля §4.9).
+ ///
+ /// Токен отмены.
+ /// Полный статус вкладки Telegram.
+ public async Task GetAsync(CancellationToken ct)
+ {
+ TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false);
+ string account = await ReadAccountAsync(ct).ConfigureAwait(false);
+ int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count;
+ TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false);
+
+ return new TgStatusDto(
+ Phase: live.Phase,
+ Connected: live.Connected,
+ Listener: live.Listener,
+ Account: account,
+ Monitored: monitored,
+ KeysSet: snapshot.KeysSet,
+ Error: live.Error,
+ QrUrl: live.QrUrl);
+ }
+
+ // Живой статус из гейта; сбой (сервис недоступен/нет сессии) → idle-форма (Ruling 8).
+ // ct: Токен отмены.
+ // Возвращает: Статус гейта либо idle-поля.
+ private async Task ReadLiveAsync(CancellationToken ct)
+ {
+ try
+ {
+ return await gateway.StatusAsync(ct).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested)
+ {
+ // «Сервис недоступен → idle-форма» (Ruling 8): connected=false, live-поля пусты. Аккаунт/счётчики
+ // ядро всё равно докладывает из своего KV/БД (ниже) — как python при отключённом клиенте.
+ return new TelegramAccountStatusDto(IdlePhase, Connected: false, Listener: false, string.Empty, null, null);
+ }
+ }
+
+ // Аккаунт «@username» из KV tgAccount (JSON-строка, пишет ReportStatus ингресса, Ruling 7).
+ // ct: Токен отмены.
+ // Возвращает: Аккаунт или пустая строка.
+ private async Task ReadAccountAsync(CancellationToken ct)
+ {
+ SettingValue? row = await settings.GetAsync(SettingsKeys.TgAccount, ct).ConfigureAwait(false);
+ if (row is null)
+ {
+ return string.Empty;
+ }
+
+ try
+ {
+ using JsonDocument document = JsonDocument.Parse(row.ValueJson);
+ return document.RootElement.ValueKind == JsonValueKind.String
+ ? document.RootElement.GetString() ?? string.Empty
+ : string.Empty;
+ }
+ catch (JsonException)
+ {
+ return string.Empty;
+ }
+ }
+}
diff --git a/src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs b/src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs
index 27409b2..dc48169 100644
--- a/src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs
+++ b/src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs
@@ -1,7 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
-using Deal.Modules.Settings.Application;
+using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
namespace Deal.Infrastructure.Integrations;
diff --git a/src/core/Deal.Infrastructure/Integrations/AiProviderConfigBuilder.cs b/src/core/Deal.Infrastructure/Integrations/AiProviderConfigBuilder.cs
index 8f7f8c9..dcd2e42 100644
--- a/src/core/Deal.Infrastructure/Integrations/AiProviderConfigBuilder.cs
+++ b/src/core/Deal.Infrastructure/Integrations/AiProviderConfigBuilder.cs
@@ -1,7 +1,9 @@
using System.Text.Json.Nodes;
using Deal.Grpc.Ai;
-using Deal.Modules.Settings.Application;
+using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
namespace Deal.Infrastructure.Integrations;
diff --git a/src/core/Deal.Infrastructure/Integrations/BudgetedAiClassifier.cs b/src/core/Deal.Infrastructure/Integrations/BudgetedAiClassifier.cs
index a816949..1898b88 100644
--- a/src/core/Deal.Infrastructure/Integrations/BudgetedAiClassifier.cs
+++ b/src/core/Deal.Infrastructure/Integrations/BudgetedAiClassifier.cs
@@ -1,7 +1,10 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.Logging;
diff --git a/src/core/Deal.Infrastructure/Integrations/BudgetedAiTools.cs b/src/core/Deal.Infrastructure/Integrations/BudgetedAiTools.cs
index dd5746c..4ed6e6c 100644
--- a/src/core/Deal.Infrastructure/Integrations/BudgetedAiTools.cs
+++ b/src/core/Deal.Infrastructure/Integrations/BudgetedAiTools.cs
@@ -1,7 +1,10 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.Logging;
diff --git a/src/core/Deal.Infrastructure/Integrations/CbrRateSource.cs b/src/core/Deal.Infrastructure/Integrations/CbrRateSource.cs
index baebc82..c35db28 100644
--- a/src/core/Deal.Infrastructure/Integrations/CbrRateSource.cs
+++ b/src/core/Deal.Infrastructure/Integrations/CbrRateSource.cs
@@ -1,6 +1,9 @@
using System.Globalization;
using System.Text.Json;
-using Deal.Modules.Settings.Application;
+using Deal.Modules.Settings.Application.Abstractions;
+using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations;
diff --git a/src/core/Deal.Infrastructure/Integrations/GrpcAiClassifier.cs b/src/core/Deal.Infrastructure/Integrations/GrpcAiClassifier.cs
index 0985f95..453be72 100644
--- a/src/core/Deal.Infrastructure/Integrations/GrpcAiClassifier.cs
+++ b/src/core/Deal.Infrastructure/Integrations/GrpcAiClassifier.cs
@@ -1,7 +1,10 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai;
-using Deal.Modules.Pipeline.Application;
+using Deal.Modules.Pipeline.Application.Abstractions;
+using Deal.Modules.Pipeline.Application.Models;
+using Deal.Modules.Pipeline.Application.Registrars;
+using Deal.Modules.Pipeline.Application.Services;
using Deal.SharedKernel.Tenants;
using Grpc.Core;
using Microsoft.Extensions.Logging;
diff --git a/src/core/Deal.Infrastructure/Integrations/GrpcMlClient.cs b/src/core/Deal.Infrastructure/Integrations/GrpcMlClient.cs
index 89e8ae8..c186914 100644
--- a/src/core/Deal.Infrastructure/Integrations/GrpcMlClient.cs
+++ b/src/core/Deal.Infrastructure/Integrations/GrpcMlClient.cs
@@ -2,11 +2,20 @@ using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ml;
-using Deal.Modules.Kanban.Application;
+using Deal.Modules.Kanban.Application.Abstractions;
+using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
-using Deal.Modules.Settings.Application;
+using Deal.Modules.Kanban.Application.Registrars;
+using Deal.Modules.Kanban.Application.Services;
+using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants;
using Grpc.Core;
using Microsoft.Extensions.Logging;
diff --git a/src/core/Deal.Infrastructure/Integrations/LocalAiClassifier.cs b/src/core/Deal.Infrastructure/Integrations/LocalAiClassifier.cs
index a9320f6..5a3abeb 100644
--- a/src/core/Deal.Infrastructure/Integrations/LocalAiClassifier.cs
+++ b/src/core/Deal.Infrastructure/Integrations/LocalAiClassifier.cs
@@ -1,7 +1,9 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
-using Deal.Modules.Pipeline.Application;
+using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
+using Deal.Modules.Pipeline.Application.Registrars;
+using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Pipeline.Application.Parse;
namespace Deal.Infrastructure.Integrations;
diff --git a/src/core/Deal.Infrastructure/Integrations/LocalColumnSuggester.cs b/src/core/Deal.Infrastructure/Integrations/LocalColumnSuggester.cs
index e9c8b5d..84cae4f 100644
--- a/src/core/Deal.Infrastructure/Integrations/LocalColumnSuggester.cs
+++ b/src/core/Deal.Infrastructure/Integrations/LocalColumnSuggester.cs
@@ -1,247 +1,252 @@
-using System.Globalization;
-using System.Text.Json;
-using Deal.Contracts.Integrations;
-using Deal.Contracts.Integrations.Models;
-using Deal.Modules.Cards.Application.Abstractions;
-using Deal.Modules.Cards.Application.Dtos;
-using Deal.Modules.Cards.Application.Models;
-using Deal.Modules.Kanban.Application;
-using Deal.Modules.Kanban.Application.Models;
-using Deal.Modules.Settings.Application;
-using Deal.Modules.Settings.Application.Models;
-// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) —
-// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство.
-using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
-
-namespace Deal.Infrastructure.Integrations;
-
-///
-/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика этапа 3 (Ruling 3, план Task 14).
-///
-///
-/// Реализует порт поверх порта и чистого ядра
-/// (модуль Kanban): читает «Неразобранное» (ListInboxWithSourceAsync),
-/// считает группы слов-тем и создаёт доски suggested=true (RulesJson {mode:"any", keywords:[…]},
-/// note-обоснование, цвет/позицию даёт ContainersService) и раскладывает карточки (is_new=TRUE,
-/// prev_col='inbox', matchHits по правилам доски — Ruling 2). Причины отказов — детерминированные
-/// строки прототипа/Ruling 3: «мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже
-/// есть или нечего сгруппировать»; кулдаун повторов — KV-ключ
-/// (прототип COOLDOWN_S L51 + «недавно предлагали — подождите» L95). Журнал CardMoves/ML-сигналы при
-/// раскладке НЕ пишутся (suggest.py _assign_ids L220–239 — это не действие пользователя, а предложение).
-/// Suggest-keywords читает карточки вне trash/archive (suggest_domain_keywords L172–178).
-///
-/// Порт хранилища (карточки «Неразобранного», переносы в колонки-доски).
-/// KV-хранилище настроек тенанта (кулдаун lastSuggestAt, как KEY suggest.py L52).
-/// Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами.
-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";
-
- ///
- public async Task SuggestColumnsAsync(CancellationToken ct)
- {
- if (await WithinCooldownAsync(ct))
- {
- return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: CooldownReason, Cooldown: true);
- }
-
- IReadOnlyList inbox = await store.ListInboxWithSourceAsync(ct);
- if (inbox.Count < SuggestHeuristics.MinInbox)
- {
- return new SuggestColumnsResultDto(
- Ok: false,
- Created: 0,
- Reason: string.Format(TooFewCardsReasonFormat, SuggestHeuristics.MinInbox),
- Cooldown: false);
- }
-
- // Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138–139).
- IReadOnlyList containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct);
- IReadOnlyList existingNames = containers
- .Where(container => !container.Suggested)
- .Select(container => container.Name)
- .ToList();
-
- IReadOnlyList plans = SuggestHeuristics.PlanColumns(inbox, existingNames);
- if (plans.Count == 0)
- {
- return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
- }
-
- int created = await StoreSuggestedColumnsAsync(inbox, plans, ct);
- if (created == 0)
- {
- // Все колонки откатаны: карточки групп разобраны между чтением и раскладкой (suggest.py L153–156).
- return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
- }
-
- await WriteLastSuggestAtAsync(ct);
- return new SuggestColumnsResultDto(Ok: true, Created: created, Reason: null, Cooldown: false);
- }
-
- ///
- public async Task SuggestKeywordsAsync(CancellationToken ct)
- {
- // Выборка ключей — как suggest_domain_keywords L172–176: карточки вне trash/archive с текстом,
- // свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC).
- IReadOnlyList cards = await store.ListCardsAsync(new CardsQuery(null), ct);
- List texts = cards
- .Where(card => card.Col != CardIds.Trash
- && card.Col != CardIds.Archive
- && card.SourceMsg.Trim().Length > 0)
- .Take(SuggestHeuristics.KeywordsSampleLimit)
- .Select(card => card.SourceMsg.Trim())
- .ToList();
- if (texts.Count < SuggestHeuristics.MinKeywordsSample)
- {
- return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsTooFewReason);
- }
-
- IReadOnlyList keywords = SuggestHeuristics.SuggestDomainKeywords(texts);
- if (keywords.Count == 0)
- {
- return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsEmptyReason);
- }
-
- return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null);
- }
-
- // Создаёт доски-предложения по планам и раскладывает карточки (suggest.py L129–156).
- // inbox: Снимок «Неразобранного» (карточки планов берутся из него).
- // plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4).
- // ct: Токен отмены.
- // Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек).
- // Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием.
- // Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и
- // раскладкой (пользователь/тик), пропускаются — 1:1 со страховкой _assign_ids L231–233. Если в
- // колонку не легло ни одной карточки, пустая доска-предложение откатывается (_rollback_suggested
- // L242–248). matchHits считаются по правилам созданной доски (Ruling 2); журнал/ML не пишутся.
- private async Task StoreSuggestedColumnsAsync(
- IReadOnlyList inbox,
- IReadOnlyList plans,
- CancellationToken ct)
- {
- // Свежий снимок inbox — страховка «карточку уже разобрали» (suggest.py _assign_ids L231–233).
- HashSet inboxIds = (await store.ListInboxWithSourceAsync(ct))
- .Select(card => card.Id)
- .ToHashSet(StringComparer.Ordinal);
- Dictionary textByCardId = inbox
- .ToDictionary(card => card.Id, card => card.SourceMsg, StringComparer.Ordinal);
-
- int created = 0;
- foreach (SuggestedColumnPlan plan in plans)
- {
- var rules = new ContainerRulesDto(
- Mode: RulesModeAny,
- Direction: Array.Empty(),
- Keywords: [plan.Word],
- Stack: Array.Empty(),
- Grade: Array.Empty(),
- Exclude: Array.Empty(),
- Budget: null);
- ContainerDto container = await containersService.CreateAsync(new ContainerCreateDto(
- Name: plan.Name,
- Description: string.Empty,
- Color: null,
- Space: ContainerSpaces.Dashboard,
- Kind: ContainerKinds.Board,
- Suggested: true,
- Rules: rules,
- Note: plan.Note), ct);
-
- int placed = 0;
- foreach (string cardId in plan.CardIds)
- {
- if (!inboxIds.Contains(cardId))
- {
- continue; // карточка уже разобрана другим предложением/пользователем (L231–233)
- }
-
- IReadOnlyList hits = KanbanColumnRules.ComputeHits(rules, textByCardId[cardId]);
- await store.UpdateColumnAsync(new CardColumnUpdateDto(
- CardId: cardId,
- Col: container.Id,
- IsNew: true,
- PrevCol: CardIds.Inbox,
- ArchivedAt: null,
- MatchHits: hits), ct);
- placed++;
- }
-
- if (placed == 0)
- {
- // Ничего не легло — пустое предложение не нужно (suggest.py L152–156).
- await containersService.DeleteAsync(container.Id, ct);
- continue;
- }
-
- created++;
- }
-
- return created;
- }
-
- // Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут.
- // Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение
- // пишется только после успеха, L160–161; битый KV — дефолт «никогда»).
- // ct: Токен отмены.
- // Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}).
- private async Task WithinCooldownAsync(CancellationToken ct)
- {
- SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct);
- if (row is null)
- {
- return false;
- }
-
- try
- {
- using JsonDocument document = JsonDocument.Parse(row.ValueJson);
- if (document.RootElement.ValueKind != JsonValueKind.Number)
- {
- return false;
- }
-
- long lastSuggestAt = document.RootElement.GetInt64();
- return DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lastSuggestAt < CooldownSeconds;
- }
- catch (JsonException)
- {
- return false; // повреждённое значение не должно блокировать предложения
- }
- }
-
- // Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())).
- // ct: Токен отмены.
- private Task WriteLastSuggestAtAsync(CancellationToken ct) =>
- settings.SetAsync(
- SettingsKeys.LastSuggestAt,
- DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
- ct);
-}
+using System.Globalization;
+using System.Text.Json;
+using Deal.Contracts.Integrations;
+using Deal.Contracts.Integrations.Models;
+using Deal.Modules.Cards.Application.Abstractions;
+using Deal.Modules.Cards.Application.Dtos;
+using Deal.Modules.Cards.Application.Models;
+using Deal.Modules.Kanban.Application.Abstractions;
+using Deal.Modules.Kanban.Application.Extensions;
+using Deal.Modules.Kanban.Application.Models;
+using Deal.Modules.Kanban.Application.Registrars;
+using Deal.Modules.Kanban.Application.Services;
+using Deal.Modules.Settings.Application.Abstractions;
+using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
+// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) —
+// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство.
+using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
+
+namespace Deal.Infrastructure.Integrations;
+
+///
+/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика этапа 3 (Ruling 3, план Task 14).
+///
+///
+/// Реализует порт поверх порта и чистого ядра
+/// (модуль Kanban): читает «Неразобранное» (ListInboxWithSourceAsync),
+/// считает группы слов-тем и создаёт доски suggested=true (RulesJson {mode:"any", keywords:[…]},
+/// note-обоснование, цвет/позицию даёт ContainersService) и раскладывает карточки (is_new=TRUE,
+/// prev_col='inbox', matchHits по правилам доски — Ruling 2). Причины отказов — детерминированные
+/// строки прототипа/Ruling 3: «мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже
+/// есть или нечего сгруппировать»; кулдаун повторов — KV-ключ
+/// (прототип COOLDOWN_S L51 + «недавно предлагали — подождите» L95). Журнал CardMoves/ML-сигналы при
+/// раскладке НЕ пишутся (suggest.py _assign_ids L220–239 — это не действие пользователя, а предложение).
+/// Suggest-keywords читает карточки вне trash/archive (suggest_domain_keywords L172–178).
+///
+/// Порт хранилища (карточки «Неразобранного», переносы в колонки-доски).
+/// KV-хранилище настроек тенанта (кулдаун lastSuggestAt, как KEY suggest.py L52).
+/// Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами.
+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";
+
+ ///
+ public async Task SuggestColumnsAsync(CancellationToken ct)
+ {
+ if (await WithinCooldownAsync(ct))
+ {
+ return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: CooldownReason, Cooldown: true);
+ }
+
+ IReadOnlyList inbox = await store.ListInboxWithSourceAsync(ct);
+ if (inbox.Count < SuggestHeuristics.MinInbox)
+ {
+ return new SuggestColumnsResultDto(
+ Ok: false,
+ Created: 0,
+ Reason: string.Format(TooFewCardsReasonFormat, SuggestHeuristics.MinInbox),
+ Cooldown: false);
+ }
+
+ // Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138–139).
+ IReadOnlyList containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct);
+ IReadOnlyList existingNames = containers
+ .Where(container => !container.Suggested)
+ .Select(container => container.Name)
+ .ToList();
+
+ IReadOnlyList plans = SuggestHeuristics.PlanColumns(inbox, existingNames);
+ if (plans.Count == 0)
+ {
+ return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
+ }
+
+ int created = await StoreSuggestedColumnsAsync(inbox, plans, ct);
+ if (created == 0)
+ {
+ // Все колонки откатаны: карточки групп разобраны между чтением и раскладкой (suggest.py L153–156).
+ return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
+ }
+
+ await WriteLastSuggestAtAsync(ct);
+ return new SuggestColumnsResultDto(Ok: true, Created: created, Reason: null, Cooldown: false);
+ }
+
+ ///
+ public async Task SuggestKeywordsAsync(CancellationToken ct)
+ {
+ // Выборка ключей — как suggest_domain_keywords L172–176: карточки вне trash/archive с текстом,
+ // свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC).
+ IReadOnlyList cards = await store.ListCardsAsync(new CardsQuery(null), ct);
+ List texts = cards
+ .Where(card => card.Col != CardIds.Trash
+ && card.Col != CardIds.Archive
+ && card.SourceMsg.Trim().Length > 0)
+ .Take(SuggestHeuristics.KeywordsSampleLimit)
+ .Select(card => card.SourceMsg.Trim())
+ .ToList();
+ if (texts.Count < SuggestHeuristics.MinKeywordsSample)
+ {
+ return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsTooFewReason);
+ }
+
+ IReadOnlyList keywords = SuggestHeuristics.SuggestDomainKeywords(texts);
+ if (keywords.Count == 0)
+ {
+ return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsEmptyReason);
+ }
+
+ return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null);
+ }
+
+ // Создаёт доски-предложения по планам и раскладывает карточки (suggest.py L129–156).
+ // inbox: Снимок «Неразобранного» (карточки планов берутся из него).
+ // plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4).
+ // ct: Токен отмены.
+ // Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек).
+ // Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием.
+ // Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и
+ // раскладкой (пользователь/тик), пропускаются — 1:1 со страховкой _assign_ids L231–233. Если в
+ // колонку не легло ни одной карточки, пустая доска-предложение откатывается (_rollback_suggested
+ // L242–248). matchHits считаются по правилам созданной доски (Ruling 2); журнал/ML не пишутся.
+ private async Task StoreSuggestedColumnsAsync(
+ IReadOnlyList inbox,
+ IReadOnlyList plans,
+ CancellationToken ct)
+ {
+ // Свежий снимок inbox — страховка «карточку уже разобрали» (suggest.py _assign_ids L231–233).
+ HashSet inboxIds = (await store.ListInboxWithSourceAsync(ct))
+ .Select(card => card.Id)
+ .ToHashSet(StringComparer.Ordinal);
+ Dictionary textByCardId = inbox
+ .ToDictionary(card => card.Id, card => card.SourceMsg, StringComparer.Ordinal);
+
+ int created = 0;
+ foreach (SuggestedColumnPlan plan in plans)
+ {
+ var rules = new ContainerRulesDto(
+ Mode: RulesModeAny,
+ Direction: Array.Empty(),
+ Keywords: [plan.Word],
+ Stack: Array.Empty(),
+ Grade: Array.Empty(),
+ Exclude: Array.Empty(),
+ Budget: null);
+ ContainerDto container = await containersService.CreateAsync(new ContainerCreateDto(
+ Name: plan.Name,
+ Description: string.Empty,
+ Color: null,
+ Space: ContainerSpaces.Dashboard,
+ Kind: ContainerKinds.Board,
+ Suggested: true,
+ Rules: rules,
+ Note: plan.Note), ct);
+
+ int placed = 0;
+ foreach (string cardId in plan.CardIds)
+ {
+ if (!inboxIds.Contains(cardId))
+ {
+ continue; // карточка уже разобрана другим предложением/пользователем (L231–233)
+ }
+
+ IReadOnlyList hits = KanbanColumnRules.ComputeHits(rules, textByCardId[cardId]);
+ await store.UpdateColumnAsync(new CardColumnUpdateDto(
+ CardId: cardId,
+ Col: container.Id,
+ IsNew: true,
+ PrevCol: CardIds.Inbox,
+ ArchivedAt: null,
+ MatchHits: hits), ct);
+ placed++;
+ }
+
+ if (placed == 0)
+ {
+ // Ничего не легло — пустое предложение не нужно (suggest.py L152–156).
+ await containersService.DeleteAsync(container.Id, ct);
+ continue;
+ }
+
+ created++;
+ }
+
+ return created;
+ }
+
+ // Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут.
+ // Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение
+ // пишется только после успеха, L160–161; битый KV — дефолт «никогда»).
+ // ct: Токен отмены.
+ // Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}).
+ private async Task WithinCooldownAsync(CancellationToken ct)
+ {
+ SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct);
+ if (row is null)
+ {
+ return false;
+ }
+
+ try
+ {
+ using JsonDocument document = JsonDocument.Parse(row.ValueJson);
+ if (document.RootElement.ValueKind != JsonValueKind.Number)
+ {
+ return false;
+ }
+
+ long lastSuggestAt = document.RootElement.GetInt64();
+ return DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lastSuggestAt < CooldownSeconds;
+ }
+ catch (JsonException)
+ {
+ return false; // повреждённое значение не должно блокировать предложения
+ }
+ }
+
+ // Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())).
+ // ct: Токен отмены.
+ private Task WriteLastSuggestAtAsync(CancellationToken ct) =>
+ settings.SetAsync(
+ SettingsKeys.LastSuggestAt,
+ DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
+ ct);
+}
diff --git a/src/core/Deal.Infrastructure/Integrations/LocalMlClient.cs b/src/core/Deal.Infrastructure/Integrations/LocalMlClient.cs
index 004b918..85ff5e1 100644
--- a/src/core/Deal.Infrastructure/Integrations/LocalMlClient.cs
+++ b/src/core/Deal.Infrastructure/Integrations/LocalMlClient.cs
@@ -1,9 +1,15 @@
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
-using Deal.Modules.Kanban.Application;
-using Deal.Modules.Settings.Application;
+using Deal.Modules.Kanban.Application.Abstractions;
+using Deal.Modules.Kanban.Application.Extensions;
+using Deal.Modules.Kanban.Application.Models;
+using Deal.Modules.Kanban.Application.Registrars;
+using Deal.Modules.Kanban.Application.Services;
+using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
namespace Deal.Infrastructure.Integrations;
diff --git a/src/core/Deal.Infrastructure/Integrations/MlOutboxQueue.cs b/src/core/Deal.Infrastructure/Integrations/MlOutboxQueue.cs
index 9fd439a..97f86a3 100644
--- a/src/core/Deal.Infrastructure/Integrations/MlOutboxQueue.cs
+++ b/src/core/Deal.Infrastructure/Integrations/MlOutboxQueue.cs
@@ -1,5 +1,9 @@
using System.Security.Cryptography;
-using Deal.Modules.Kanban.Application;
+using Deal.Modules.Kanban.Application.Abstractions;
+using Deal.Modules.Kanban.Application.Extensions;
+using Deal.Modules.Kanban.Application.Models;
+using Deal.Modules.Kanban.Application.Registrars;
+using Deal.Modules.Kanban.Application.Services;
namespace Deal.Infrastructure.Integrations;
diff --git a/src/core/Deal.Infrastructure/Integrations/TokenUsageRecorder.cs b/src/core/Deal.Infrastructure/Integrations/TokenUsageRecorder.cs
index 0cb46af..9a0ee63 100644
--- a/src/core/Deal.Infrastructure/Integrations/TokenUsageRecorder.cs
+++ b/src/core/Deal.Infrastructure/Integrations/TokenUsageRecorder.cs
@@ -1,10 +1,15 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Deal.Grpc.Ai;
-using Deal.Modules.Settings.Application;
+using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Observability;
using Deal.SharedKernel.Tenants;
diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs
index 0a2b198..2ae60f0 100644
--- a/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs
+++ b/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs
@@ -6,7 +6,7 @@ namespace Deal.Infrastructure.Persistence.Entities;
///
/// Единое KV-хранилище всего SaaS-контура (ТЗ §4.1/§8.1): значения задаёт оператор, видят все
/// тенанты. Секреты хранятся зашифрованными (префикс enc:), формат значения определяет ключ
-/// ().
+/// ().
///
public sealed class GlobalSettingEntity
{
diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs
index 33ccf00..3b6a0fd 100644
--- a/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs
+++ b/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs
index 43c4640..eebb784 100644
--- a/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs
+++ b/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities;
-using Deal.Modules.Tenants.Application;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs
index 7d55e80..9f12e85 100644
--- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs
+++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs
@@ -1,65 +1,69 @@
-using System.Text.Json;
-using Deal.Infrastructure.Persistence.Entities;
-using Deal.Modules.Discovery.Application;
-using Deal.Modules.Discovery.Application.Models;
-using Microsoft.EntityFrameworkCore;
-
-namespace Deal.Infrastructure.Persistence.Repositories;
-
-///
-/// Чёрный список Discovery — partial-часть (C32: выделено из общего
-/// файла, поведение не менялось): upsert/удаление/чтение/список DiscBlacklist (ON CONFLICT DO UPDATE).
-///
-public sealed partial class DiscoveryStore
-{
- ///
- public async Task UpsertBlacklistAsync(string dialogId, string name, string reason, CancellationToken ct)
- {
- DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
- .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
- if (row is null)
- {
- _dbContext.DiscBlacklist.Add(new DiscBlacklistEntity
- {
- DialogId = dialogId,
- Name = name,
- Reason = reason,
- CreatedAt = DateTimeOffset.UtcNow,
- });
- }
- else
- {
- // add_blacklist L572–575 (ON CONFLICT DO UPDATE): name/reason обновляются, CreatedAt сохраняется.
- row.Name = name;
- row.Reason = reason;
- }
-
- await _dbContext.SaveChangesAsync(ct);
- }
-
- ///
- public async Task RemoveBlacklistAsync(string dialogId, CancellationToken ct)
- {
- await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct);
- }
-
- ///
- public async Task GetBlacklistAsync(string dialogId, CancellationToken ct)
- {
- DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
- .AsNoTracking()
- .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
- return row is null ? null : ToBlacklistDto(row);
- }
-
- ///
- public async Task> ListBlacklistAsync(CancellationToken ct)
- {
- List rows = await _dbContext.DiscBlacklist
- .AsNoTracking()
- .OrderByDescending(entry => entry.CreatedAt)
- .ToListAsync(ct);
- return rows.Select(ToBlacklistDto).ToList();
- }
-
-}
+using System.Text.Json;
+using Deal.Infrastructure.Persistence.Entities;
+using Deal.Modules.Discovery.Application.Abstractions;
+using Deal.Modules.Discovery.Application.Exceptions;
+using Deal.Modules.Discovery.Application.Extensions;
+using Deal.Modules.Discovery.Application.Models;
+using Deal.Modules.Discovery.Application.Registrars;
+using Deal.Modules.Discovery.Application.Services;
+using Microsoft.EntityFrameworkCore;
+
+namespace Deal.Infrastructure.Persistence.Repositories;
+
+///
+/// Чёрный список Discovery — partial-часть (C32: выделено из общего
+/// файла, поведение не менялось): upsert/удаление/чтение/список DiscBlacklist (ON CONFLICT DO UPDATE).
+///
+public sealed partial class DiscoveryStore
+{
+ ///
+ public async Task UpsertBlacklistAsync(string dialogId, string name, string reason, CancellationToken ct)
+ {
+ DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
+ .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
+ if (row is null)
+ {
+ _dbContext.DiscBlacklist.Add(new DiscBlacklistEntity
+ {
+ DialogId = dialogId,
+ Name = name,
+ Reason = reason,
+ CreatedAt = DateTimeOffset.UtcNow,
+ });
+ }
+ else
+ {
+ // add_blacklist L572–575 (ON CONFLICT DO UPDATE): name/reason обновляются, CreatedAt сохраняется.
+ row.Name = name;
+ row.Reason = reason;
+ }
+
+ await _dbContext.SaveChangesAsync(ct);
+ }
+
+ ///
+ public async Task RemoveBlacklistAsync(string dialogId, CancellationToken ct)
+ {
+ await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct);
+ }
+
+ ///
+ public async Task GetBlacklistAsync(string dialogId, CancellationToken ct)
+ {
+ DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
+ .AsNoTracking()
+ .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
+ return row is null ? null : ToBlacklistDto(row);
+ }
+
+ ///
+ public async Task> ListBlacklistAsync(CancellationToken ct)
+ {
+ List rows = await _dbContext.DiscBlacklist
+ .AsNoTracking()
+ .OrderByDescending(entry => entry.CreatedAt)
+ .ToListAsync(ct);
+ return rows.Select(ToBlacklistDto).ToList();
+ }
+
+}
diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs
index 91255df..454d4c5 100644
--- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs
+++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs
@@ -1,158 +1,162 @@
-using System.Text.Json;
-using Deal.Infrastructure.Persistence.Entities;
-using Deal.Modules.Discovery.Application;
-using Deal.Modules.Discovery.Application.Models;
-using Microsoft.EntityFrameworkCore;
-
-namespace Deal.Infrastructure.Persistence.Repositories;
-
-///
-/// Кандидаты Discovery — partial-часть (C32: выделено из общего файла,
-/// поведение не менялось): список/чтение кандидатов (DiscCandidates), мониторинг диалога и чёрный список,
-/// создание/патч/статусы joined/rejected и счётчик сбоев вступлений.
-///
-public sealed partial class DiscoveryStore
-{
- ///
- public async Task> ListCandidatesAsync(string taskId, string? status, CancellationToken ct)
- {
- IQueryable query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId);
- if (status is not null)
- {
- query = query.Where(candidate => candidate.Status == status);
- }
-
- List rows = await query.OrderBy(candidate => candidate.CreatedAt).ToListAsync(ct);
- return rows.Select(ToCandidateDto).ToList();
- }
-
- ///
- public async Task GetCandidateAsync(string dialogId, CancellationToken ct)
- {
- DiscCandidateEntity? row = await _dbContext.DiscCandidates
- .AsNoTracking()
- .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
- return row is null ? null : ToCandidateDto(row);
- }
-
- ///
- public async Task IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
- {
- return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct);
- }
-
- ///
- public Task IsBlacklistedAsync(string dialogId, CancellationToken ct)
- {
- return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct);
- }
-
- ///
- public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
- {
- DateTimeOffset now = DateTimeOffset.UtcNow;
- _dbContext.DiscCandidates.Add(new DiscCandidateEntity
- {
- DialogId = row.DialogId,
- TaskId = row.TaskId,
- Name = row.Name,
- Username = row.Username,
- Kind = row.Kind,
- Hue = row.Hue,
- Status = "new",
- CreatedAt = now,
- UpdatedAt = now,
- });
- await _dbContext.SaveChangesAsync(ct);
- }
-
- ///
- public async Task DeleteCandidateAsync(string dialogId, CancellationToken ct)
- {
- await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct);
- }
-
- ///
- public async Task PatchCandidateAsync(string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct)
- {
- DiscCandidateEntity? row = await _dbContext.DiscCandidates
- .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
- if (row is null)
- {
- return false;
- }
-
- ApplyCandidatePatch(row, patch);
- row.UpdatedAt = DateTimeOffset.UtcNow;
- await _dbContext.SaveChangesAsync(ct);
- return true;
- }
-
- ///
- public async Task SetCandidateStatusAsync(string dialogId, string status, CancellationToken ct)
- {
- DiscCandidateEntity? row = await _dbContext.DiscCandidates
- .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
- if (row is null)
- {
- return false;
- }
-
- row.Status = status;
- row.UpdatedAt = DateTimeOffset.UtcNow;
- await _dbContext.SaveChangesAsync(ct);
- return true;
- }
-
- ///
- public async Task SetCandidateJoinedAsync(string dialogId, bool autoJoined, CancellationToken ct)
- {
- DiscCandidateEntity? row = await _dbContext.DiscCandidates
- .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
- if (row is null)
- {
- return false;
- }
-
- // mark_joined L531–534: status=joined + auto_joined.
- row.Status = "joined";
- row.AutoJoined = autoJoined;
- row.UpdatedAt = DateTimeOffset.UtcNow;
- await _dbContext.SaveChangesAsync(ct);
- return true;
- }
-
- ///
- public async Task IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
- {
- DiscCandidateEntity? row = await _dbContext.DiscCandidates
- .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
- if (row is null || row.Status != "review")
- {
- // воркер L404–416: счётчик и удаление трогаем только у живой записи в статусе review.
- return null;
- }
-
- row.JoinFailures += 1;
- row.UpdatedAt = DateTimeOffset.UtcNow;
- await _dbContext.SaveChangesAsync(ct);
- return row.JoinFailures;
- }
-
- ///
- public async Task SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
- {
- DiscCandidateEntity? row = await _dbContext.DiscCandidates
- .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
- if (row is null)
- {
- return false;
- }
-
- row.Status = "rejected";
- row.UpdatedAt = DateTimeOffset.UtcNow;
- await _dbContext.SaveChangesAsync(ct);
- return true;
- }
-
-}
+using System.Text.Json;
+using Deal.Infrastructure.Persistence.Entities;
+using Deal.Modules.Discovery.Application.Abstractions;
+using Deal.Modules.Discovery.Application.Exceptions;
+using Deal.Modules.Discovery.Application.Extensions;
+using Deal.Modules.Discovery.Application.Models;
+using Deal.Modules.Discovery.Application.Registrars;
+using Deal.Modules.Discovery.Application.Services;
+using Microsoft.EntityFrameworkCore;
+
+namespace Deal.Infrastructure.Persistence.Repositories;
+
+///
+/// Кандидаты Discovery — partial-часть (C32: выделено из общего файла,
+/// поведение не менялось): список/чтение кандидатов (DiscCandidates), мониторинг диалога и чёрный список,
+/// создание/патч/статусы joined/rejected и счётчик сбоев вступлений.
+///
+public sealed partial class DiscoveryStore
+{
+ ///
+ public async Task> ListCandidatesAsync(string taskId, string? status, CancellationToken ct)
+ {
+ IQueryable query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId);
+ if (status is not null)
+ {
+ query = query.Where(candidate => candidate.Status == status);
+ }
+
+ List rows = await query.OrderBy(candidate => candidate.CreatedAt).ToListAsync(ct);
+ return rows.Select(ToCandidateDto).ToList();
+ }
+
+ ///
+ public async Task GetCandidateAsync(string dialogId, CancellationToken ct)
+ {
+ DiscCandidateEntity? row = await _dbContext.DiscCandidates
+ .AsNoTracking()
+ .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
+ return row is null ? null : ToCandidateDto(row);
+ }
+
+ ///
+ public async Task IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
+ {
+ return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct);
+ }
+
+ ///
+ public Task IsBlacklistedAsync(string dialogId, CancellationToken ct)
+ {
+ return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct);
+ }
+
+ ///
+ public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
+ {
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ _dbContext.DiscCandidates.Add(new DiscCandidateEntity
+ {
+ DialogId = row.DialogId,
+ TaskId = row.TaskId,
+ Name = row.Name,
+ Username = row.Username,
+ Kind = row.Kind,
+ Hue = row.Hue,
+ Status = "new",
+ CreatedAt = now,
+ UpdatedAt = now,
+ });
+ await _dbContext.SaveChangesAsync(ct);
+ }
+
+ ///
+ public async Task DeleteCandidateAsync(string dialogId, CancellationToken ct)
+ {
+ await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct);
+ }
+
+ ///
+ public async Task PatchCandidateAsync(string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct)
+ {
+ DiscCandidateEntity? row = await _dbContext.DiscCandidates
+ .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
+ if (row is null)
+ {
+ return false;
+ }
+
+ ApplyCandidatePatch(row, patch);
+ row.UpdatedAt = DateTimeOffset.UtcNow;
+ await _dbContext.SaveChangesAsync(ct);
+ return true;
+ }
+
+ ///
+ public async Task SetCandidateStatusAsync(string dialogId, string status, CancellationToken ct)
+ {
+ DiscCandidateEntity? row = await _dbContext.DiscCandidates
+ .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
+ if (row is null)
+ {
+ return false;
+ }
+
+ row.Status = status;
+ row.UpdatedAt = DateTimeOffset.UtcNow;
+ await _dbContext.SaveChangesAsync(ct);
+ return true;
+ }
+
+ ///
+ public async Task SetCandidateJoinedAsync(string dialogId, bool autoJoined, CancellationToken ct)
+ {
+ DiscCandidateEntity? row = await _dbContext.DiscCandidates
+ .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
+ if (row is null)
+ {
+ return false;
+ }
+
+ // mark_joined L531–534: status=joined + auto_joined.
+ row.Status = "joined";
+ row.AutoJoined = autoJoined;
+ row.UpdatedAt = DateTimeOffset.UtcNow;
+ await _dbContext.SaveChangesAsync(ct);
+ return true;
+ }
+
+ ///
+ public async Task IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
+ {
+ DiscCandidateEntity? row = await _dbContext.DiscCandidates
+ .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
+ if (row is null || row.Status != "review")
+ {
+ // воркер L404–416: счётчик и удаление трогаем только у живой записи в статусе review.
+ return null;
+ }
+
+ row.JoinFailures += 1;
+ row.UpdatedAt = DateTimeOffset.UtcNow;
+ await _dbContext.SaveChangesAsync(ct);
+ return row.JoinFailures;
+ }
+
+ ///
+ public async Task SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
+ {
+ DiscCandidateEntity? row = await _dbContext.DiscCandidates
+ .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
+ if (row is null)
+ {
+ return false;
+ }
+
+ row.Status = "rejected";
+ row.UpdatedAt = DateTimeOffset.UtcNow;
+ await _dbContext.SaveChangesAsync(ct);
+ return true;
+ }
+
+}
diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs
index 2c54db2..543dc31 100644
--- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs
+++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs
@@ -1,47 +1,51 @@
-using System.Text.Json;
-using Deal.Infrastructure.Persistence.Entities;
-using Deal.Modules.Discovery.Application;
-using Deal.Modules.Discovery.Application.Models;
-using Microsoft.EntityFrameworkCore;
-
-namespace Deal.Infrastructure.Persistence.Repositories;
-
-///