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

Application проектов Discovery, Kanban, Pipeline, Settings, Tenants
разделён на Abstractions/Exceptions/Extensions/Models/Registrars/Services;
namespace приведён к путям, using потребителей мигрированы и
дедуплицированы (169 файлов), cref/FQN обновлены.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:18:14 +03:00
parent 31c434ed93
commit cd0b3b606b
356 changed files with 14436 additions and 12906 deletions
+7 -2
View File
@@ -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;
@@ -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;
@@ -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;
@@ -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;
+4 -1
View File
@@ -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;
@@ -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;
+12 -3
View File
@@ -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;
@@ -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;
@@ -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;
@@ -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;
+4 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
@@ -1,168 +1,171 @@
using Deal.Api.Http;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3).
/// </summary>
/// <remarks>
/// Только под операторской сессией: без неё 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).
/// </remarks>
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)";
/// <summary>
/// Регистрирует группу /api/operator/analytics: overview/tokens/activity.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
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=&amp;to=: находки детектора подозрительной активности (§10.5).
// from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа.
// to: Конец окна анализа (включительно; ISO-8601); null — «сейчас».
// context: Контекст запроса.
// suspiciousService: Детектор подозрительной активности (scoped).
// ct: Токен отмены.
// Возвращает: 200 сводка находок или 401 без операторской сессии.
private static async Task<IResult> 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=&amp;to=: сводка (тенанты, токены, события, входы/выходы).
// from: Начало периода (включительно; ISO-8601).
// to: Конец периода (включительно; ISO-8601).
// context: Контекст запроса.
// analyticsService: Сервис аналитики (scoped).
// ct: Токен отмены.
// Возвращает: 200 сводка или 401 без операторской сессии.
private static async Task<IResult> 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=&amp;tenantId=&amp;from=&amp;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<IResult> 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=&amp;actorType=&amp;actorId=&amp;tenantId=&amp;from=&amp;to=&amp;limit=&amp;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<IResult> 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);
}
/// <summary>
/// Известна ли группировка расхода токенов (day|tenant|provider|model).
/// </summary>
/// <param name="groupBy">Значение группировки.</param>
/// <returns>True — поддерживаемая группировка.</returns>
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;
/// <summary>
/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3).
/// </summary>
/// <remarks>
/// Только под операторской сессией: без неё 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).
/// </remarks>
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)";
/// <summary>
/// Регистрирует группу /api/operator/analytics: overview/tokens/activity.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
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=&amp;to=: находки детектора подозрительной активности (§10.5).
// from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа.
// to: Конец окна анализа (включительно; ISO-8601); null — «сейчас».
// context: Контекст запроса.
// suspiciousService: Детектор подозрительной активности (scoped).
// ct: Токен отмены.
// Возвращает: 200 сводка находок или 401 без операторской сессии.
private static async Task<IResult> 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=&amp;to=: сводка (тенанты, токены, события, входы/выходы).
// from: Начало периода (включительно; ISO-8601).
// to: Конец периода (включительно; ISO-8601).
// context: Контекст запроса.
// analyticsService: Сервис аналитики (scoped).
// ct: Токен отмены.
// Возвращает: 200 сводка или 401 без операторской сессии.
private static async Task<IResult> 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=&amp;tenantId=&amp;from=&amp;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<IResult> 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=&amp;actorType=&amp;actorId=&amp;tenantId=&amp;from=&amp;to=&amp;limit=&amp;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<IResult> 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);
}
/// <summary>
/// Известна ли группировка расхода токенов (day|tenant|provider|model).
/// </summary>
/// <param name="groupBy">Значение группировки.</param>
/// <returns>True — поддерживаемая группировка.</returns>
public static bool IsKnownGroupBy(string groupBy) =>
groupBy is TokenUsageGroupBys.Day or TokenUsageGroupBys.Tenant
or TokenUsageGroupBys.Provider or TokenUsageGroupBys.Model;
}
@@ -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;
@@ -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;
/// <summary>
/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1).
/// </summary>
/// <remarks>
/// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore),
/// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax).
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10). Защищённые
/// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит.
/// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4).
/// </remarks>
public static class OperatorAuthEndpoints
{
private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
private const string OperatorAuthGroupPrefix = "/api/operator/auth";
private const string OperatorAuthOpenApiTag = "operator-auth";
/// <summary>
/// Регистрирует группу /api/operator/auth: login, logout, me.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
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<IResult> LoginAsync(
LoginRequest body,
OperatorAuthService operatorAuthService,
AuditService auditService,
IOptions<OperatorCookieOptions> 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<IResult> LogoutAsync(
OperatorAuthService operatorAuthService,
IOptions<OperatorCookieOptions> 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;
/// <summary>
/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1).
/// </summary>
/// <remarks>
/// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore),
/// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax).
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10). Защищённые
/// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит.
/// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4).
/// </remarks>
public static class OperatorAuthEndpoints
{
private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
private const string OperatorAuthGroupPrefix = "/api/operator/auth";
private const string OperatorAuthOpenApiTag = "operator-auth";
/// <summary>
/// Регистрирует группу /api/operator/auth: login, logout, me.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
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<IResult> LoginAsync(
LoginRequest body,
OperatorAuthService operatorAuthService,
AuditService auditService,
IOptions<OperatorCookieOptions> 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<IResult> LogoutAsync(
OperatorAuthService operatorAuthService,
IOptions<OperatorCookieOptions> 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();
}
@@ -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;
@@ -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;
@@ -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;
/// <summary>
/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram
/// (ТЗ §4.1/§8.1).
/// </summary>
/// <remarks>
/// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram
/// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт.
/// <list type="bullet">
/// <item>GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт),
/// apiHash (маска) и keysSet;</item>
/// <item>PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно
/// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой),
/// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях).</item>
/// </list>
/// Ошибки — 400/401 <c>{detail}</c> (формат прототипа, Ruling 10).
/// </remarks>
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";
/// <summary>
/// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT).
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
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<IResult> 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<IResult> 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;
/// <summary>
/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram
/// (ТЗ §4.1/§8.1).
/// </summary>
/// <remarks>
/// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram
/// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт.
/// <list type="bullet">
/// <item>GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт),
/// apiHash (маска) и keysSet;</item>
/// <item>PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно
/// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой),
/// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях).</item>
/// </list>
/// Ошибки — 400/401 <c>{detail}</c> (формат прототипа, Ruling 10).
/// </remarks>
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";
/// <summary>
/// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT).
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
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<IResult> 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<IResult> 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();
}
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
+86 -83
View File
@@ -1,83 +1,86 @@
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
using Microsoft.Extensions.DependencyInjection;
namespace Deal.Api.Http;
/// <summary>
/// Хелпер записи аудита действий пользователей тенанта и операторов (единая точка — <see cref="AuditService"/>).
/// </summary>
/// <remarks>
/// Актор берётся из разрешённой сессии (<c>HttpContext.Items</c>, наполняют SessionMiddleware/
/// OperatorSessionMiddleware): для тенанта — <see cref="AuditActorTypes.Tenant"/> с userId/tenantId, для
/// оператора — <see cref="AuditActorTypes.Operator"/> без tenantId. IP — адрес клиента без порта.
/// Детали — минимальные, без секретов (пароли/токены/api-ключи). Нет сессии — no-op (вызывать после
/// проверки HasUser, но безопасно и без неё). Append-only, как весь аудит.
/// </remarks>
public static class AuditAppender
{
/// <summary>
/// Пишет событие действия пользователя тенанта (актор tenant).
/// </summary>
/// <param name="context">Контекст запроса (источник актора и IP).</param>
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
/// <param name="ct">Токен отмены.</param>
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<AuditService>();
await audit.AppendAsync(
new AuditRecordDto(
eventType,
AuditActorTypes.Tenant,
ActorId: user.UserId,
TenantId: user.TenantId,
Ip: ClientIp(context),
DetailJson: DetailJson(details)),
ct);
}
/// <summary>
/// Пишет событие действия оператора (актор operator, без tenantId).
/// </summary>
/// <param name="context">Контекст запроса (источник актора и IP).</param>
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
/// <param name="ct">Токен отмены.</param>
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<AuditService>();
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;
/// <summary>
/// Хелпер записи аудита действий пользователей тенанта и операторов (единая точка — <see cref="AuditService"/>).
/// </summary>
/// <remarks>
/// Актор берётся из разрешённой сессии (<c>HttpContext.Items</c>, наполняют SessionMiddleware/
/// OperatorSessionMiddleware): для тенанта — <see cref="AuditActorTypes.Tenant"/> с userId/tenantId, для
/// оператора — <see cref="AuditActorTypes.Operator"/> без tenantId. IP — адрес клиента без порта.
/// Детали — минимальные, без секретов (пароли/токены/api-ключи). Нет сессии — no-op (вызывать после
/// проверки HasUser, но безопасно и без неё). Append-only, как весь аудит.
/// </remarks>
public static class AuditAppender
{
/// <summary>
/// Пишет событие действия пользователя тенанта (актор tenant).
/// </summary>
/// <param name="context">Контекст запроса (источник актора и IP).</param>
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
/// <param name="ct">Токен отмены.</param>
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<AuditService>();
await audit.AppendAsync(
new AuditRecordDto(
eventType,
AuditActorTypes.Tenant,
ActorId: user.UserId,
TenantId: user.TenantId,
Ip: ClientIp(context),
DetailJson: DetailJson(details)),
ct);
}
/// <summary>
/// Пишет событие действия оператора (актор operator, без tenantId).
/// </summary>
/// <param name="context">Контекст запроса (источник актора и IP).</param>
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
/// <param name="ct">Токен отмены.</param>
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<AuditService>();
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();
}
+5 -1
View File
@@ -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;
@@ -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;
@@ -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;
@@ -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 — фиксируем алиасом.
@@ -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;
/// <summary>
/// Сборщик глубин очередей и активных сессий (этап 12, §10.2): единый источник для метрик и health.
/// </summary>
/// <remarks>
/// <para>
/// Обходит реестр тенантов (на тенант — вложенный scope с <c>ITenantContext.SetTenant</c>) и считает через
/// существующие сервисы/порты, без дублирования SQL:
/// <list type="bullet">
/// <item>глубина очереди пайплайна — <see cref="PipelineProcessingService.QueueCountsAsync"/> (new+filtered);</item>
/// <item>глубина очереди обучения ML — <see cref="IMlLearningStore.CountOutboxAsync"/> (count(MlOutbox));</item>
/// <item>активные сессии — count(public.sessions) + count(public.operator_sessions) с непросроченным ExpiresAt.</item>
/// </list>
/// Переиспользуется фоновым <see cref="DealMetricsCollector"/> (публикация в <c>DealMetrics</c>) и операторским
/// health (глубины прямо в JSON). Ошибки каждой секции логируются и не выбрасываются наружу (сбой тенанта не
/// валит проход; наружу летит только отмена); значения агрегируются по всем тенантам.
/// </para>
/// </remarks>
public sealed class RuntimeDepthsCollector
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<RuntimeDepthsCollector> _logger;
/// <summary>
/// Создаёт сборщик глубин.
/// </summary>
/// <param name="scopeFactory">Фабрика scope: проход и тенант — в собственных scope.</param>
/// <param name="logger">Логгер ошибок секций.</param>
public RuntimeDepthsCollector(IServiceScopeFactory scopeFactory, ILogger<RuntimeDepthsCollector> logger)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentNullException.ThrowIfNull(logger);
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <summary>
/// Собирает снимок глубин очередей и числа активных сессий (агрегат по всем тенантам).
/// </summary>
/// <param name="ct">Токен отмены (пробрасывается в EF-запросы; отмена — единственное исключение наружу).</param>
/// <returns>Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий.</returns>
public async Task<RuntimeDepthsDto> 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<int> CountActiveSessionsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
{
try
{
DealDbContext dbContext = cycleScope.ServiceProvider.GetRequiredService<DealDbContext>();
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<ITenantRepository>();
IReadOnlyList<TenantRecordDto> 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_&lt;N&gt;).
// 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<ITenantContext>();
try
{
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService<PipelineProcessingService>();
QueueCountsDto counts = await processing.QueueCountsAsync(ct);
IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService<IMlLearningStore>();
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;
/// <summary>
/// Сборщик глубин очередей и активных сессий (этап 12, §10.2): единый источник для метрик и health.
/// </summary>
/// <remarks>
/// <para>
/// Обходит реестр тенантов (на тенант — вложенный scope с <c>ITenantContext.SetTenant</c>) и считает через
/// существующие сервисы/порты, без дублирования SQL:
/// <list type="bullet">
/// <item>глубина очереди пайплайна — <see cref="PipelineProcessingService.QueueCountsAsync"/> (new+filtered);</item>
/// <item>глубина очереди обучения ML — <see cref="IMlLearningStore.CountOutboxAsync"/> (count(MlOutbox));</item>
/// <item>активные сессии — count(public.sessions) + count(public.operator_sessions) с непросроченным ExpiresAt.</item>
/// </list>
/// Переиспользуется фоновым <see cref="DealMetricsCollector"/> (публикация в <c>DealMetrics</c>) и операторским
/// health (глубины прямо в JSON). Ошибки каждой секции логируются и не выбрасываются наружу (сбой тенанта не
/// валит проход; наружу летит только отмена); значения агрегируются по всем тенантам.
/// </para>
/// </remarks>
public sealed class RuntimeDepthsCollector
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<RuntimeDepthsCollector> _logger;
/// <summary>
/// Создаёт сборщик глубин.
/// </summary>
/// <param name="scopeFactory">Фабрика scope: проход и тенант — в собственных scope.</param>
/// <param name="logger">Логгер ошибок секций.</param>
public RuntimeDepthsCollector(IServiceScopeFactory scopeFactory, ILogger<RuntimeDepthsCollector> logger)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentNullException.ThrowIfNull(logger);
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <summary>
/// Собирает снимок глубин очередей и числа активных сессий (агрегат по всем тенантам).
/// </summary>
/// <param name="ct">Токен отмены (пробрасывается в EF-запросы; отмена — единственное исключение наружу).</param>
/// <returns>Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий.</returns>
public async Task<RuntimeDepthsDto> 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<int> CountActiveSessionsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
{
try
{
DealDbContext dbContext = cycleScope.ServiceProvider.GetRequiredService<DealDbContext>();
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<ITenantRepository>();
IReadOnlyList<TenantRecordDto> 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_&lt;N&gt;).
// 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<ITenantContext>();
try
{
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService<PipelineProcessingService>();
QueueCountsDto counts = await processing.QueueCountsAsync(ct);
IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService<IMlLearningStore>();
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();
}
}
}
+7 -2
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -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;
@@ -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;
/// <summary>
/// gRPC-сервер входящего потока telegram-service → ядро (план Task 12, L361377; Ruling 1/7).
///
/// Реализация серверной стороны Deal.Grpc.Telegram.IngressService (telegram.proto, L380395):
/// PushMessage — новое/догоняющее сообщение мониторящегося диалога в очередь пайплайна
/// (<see cref="PipelineIngestService.EnqueueAsync"/>, тот же контракт, что приём сообщений пайплайна) в схеме тенанта
/// + превью (DialogsService.SavePreview: TgMessages + «последнее сообщение» каталога, Ruling 7);
/// SyncDialogs — применение каталога диалогов (DialogsService.SyncFromTelegram) и ответ со списком
/// monitored id (зеркало сервиса); ReportStatus — статус аккаунта в KV (tgStatus/tgAccount) + SSE
/// system_status/тосты на переходах фаз.
/// </summary>
/// <remarks>
/// Tenant-id берётся ТОЛЬКО из gRPC-metadata (полю в теле не доверяем — Ruling 1), принадлежность
/// подтверждается реестром тенантов (public.tenants), затем для работы открывается собственный scope
/// с <c>ITenantContext.SetTenant</c> (эталон PipelineWorkerScheduler, L169213): tenant-scoped адаптеры
/// (PipelineStore/SettingsStore) строятся от схемы тенанта. Неизвестный тенант/сбой схемы — RPC не падает:
/// ответ не-принято (accepted=false / ok=false, план Task 12) + лог аудита (Ruling 13); недоступный сервис
/// догоняет упущенное realtime-sweep (контракт README).
/// <para>
/// Полная синхронизация каталога (применение entries к таблице Dialogs, ответ = список monitored id) —
/// модуль Deal.Modules.Telegram (план Task 13): DialogsService.SyncFromTelegram (upsert/удаление, авто-
/// мониторинг новых по autoMonitorNew), превью сообщений — DialogsService.SavePreview (PushMessage).
/// </para>
/// </remarks>
public sealed class TelegramIngressService(
IServiceScopeFactory scopeFactory,
SseBroker broker,
ILogger<TelegramIngressService> logger) : IngressService.IngressServiceBase
{
/// <summary>
/// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1).
/// </summary>
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,
};
/// <summary>
/// PushMessage — сообщение диалога в очередь пайплайна тенанта + превью (PushMessageRequest, Ruling 7).
/// </summary>
/// <remarks>Дубль dialog_id+msg_id уже в очереди — duplicate=true, очередь не растёт (гвард
/// PipelineIngestService). Пустой текст/диалог — no-op приёма (accepted=false, контракт proto).
/// После постановки в очередь пишется превью (DialogsService.SavePreview: строка TgMessages
/// «m_&lt;dialog&gt;_&lt;msg&gt;» + «последнее сообщение» каталога — 1:1 _on_message python L270274);
/// сбой превью не влияет на приём (accepted определён очередью, лог дебага).
/// Неизвестный тенант или сбой схемы/БД — не-принято (accepted=false) без исключения RPC.</remarks>
/// <param name="request">Сообщение из потока telegram-service.</param>
/// <param name="context">Контекст вызова (metadata tenant-id + service-token).</param>
/// <returns>accepted — сообщение принято (либо дубль), duplicate — уже было в очереди.</returns>
public override async Task<PushMessageReply> 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<ITenantContext>();
try
{
// Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService<PipelineIngestService>();
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();
}
}
/// <summary>
/// SyncDialogs — синхронизация каталога диалогов аккаунта (Ruling 7, L386390).
/// </summary>
/// <remarks>
/// Модуль Deal.Modules.Telegram (план Task 13) применяет entries к таблице Dialogs
/// (DialogsService.SyncFromTelegram: upsert + удаление отсутствующих; авто-мониторинг новых — по
/// настройке autoMonitorNew). Ответ несёт актуальный список monitored id — по нему telegram-service
/// держит своё зеркало мониторинга в памяти (обновляется ответом SyncDialogs и командой SetMonitor,
/// Ruling 7) и фильтрует события realtime.
/// </remarks>
/// <param name="request">Актуальный каталог диалогов (entries).</param>
/// <param name="context">Контекст вызова.</param>
/// <returns>monitored_ids — диалоги с включённым мониторингом после применения каталога.</returns>
public override async Task<SyncDialogsReply> 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<ITenantContext>();
try
{
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService<DialogsService>();
List<TelegramDialogEntryDto> 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<string> 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();
}
}
/// <summary>
/// ReportStatus — статус аккаунта в KV + SSE system_status/тосты на переходах фаз (Ruling 7).
/// </summary>
/// <remarks>KV tgStatus (снимок без account) и tgAccount (JSON-строка) пишутся в схему тенанта;
/// system_status публикуется на каждый репорт (фронт перечитывает /api/tg/status), тосты — только на
/// переходы connected: false→true «Telegram подключён, сессия сохранена», true→false «Telegram отключён»
/// (сервис шлёт статус по событию и heartbeat'ом — без гарда переходов тосты дублировались бы).
/// Неизвестный тенант/сбой схемы — ok=false без исключения RPC (план Task 12).</remarks>
/// <param name="request">Статус аккаунта из _publish_status прототипа.</param>
/// <param name="context">Контекст вызова.</param>
/// <returns>ok — статус принят и сохранён.</returns>
public override async Task<ReportStatusReply> 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<ITenantContext>();
try
{
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
ISettingsStore settings = tenantScope.ServiceProvider.GetRequiredService<ISettingsStore>();
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<TenantRecordDto?> 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<ITenantRepository>();
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<DialogsService>();
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<TgReportedStatus?> 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<TgReportedStatus>(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;
/// <summary>
/// gRPC-сервер входящего потока telegram-service → ядро (план Task 12, L361377; Ruling 1/7).
///
/// Реализация серверной стороны Deal.Grpc.Telegram.IngressService (telegram.proto, L380395):
/// PushMessage — новое/догоняющее сообщение мониторящегося диалога в очередь пайплайна
/// (<see cref="PipelineIngestService.EnqueueAsync"/>, тот же контракт, что приём сообщений пайплайна) в схеме тенанта
/// + превью (DialogsService.SavePreview: TgMessages + «последнее сообщение» каталога, Ruling 7);
/// SyncDialogs — применение каталога диалогов (DialogsService.SyncFromTelegram) и ответ со списком
/// monitored id (зеркало сервиса); ReportStatus — статус аккаунта в KV (tgStatus/tgAccount) + SSE
/// system_status/тосты на переходах фаз.
/// </summary>
/// <remarks>
/// Tenant-id берётся ТОЛЬКО из gRPC-metadata (полю в теле не доверяем — Ruling 1), принадлежность
/// подтверждается реестром тенантов (public.tenants), затем для работы открывается собственный scope
/// с <c>ITenantContext.SetTenant</c> (эталон PipelineWorkerScheduler, L169213): tenant-scoped адаптеры
/// (PipelineStore/SettingsStore) строятся от схемы тенанта. Неизвестный тенант/сбой схемы — RPC не падает:
/// ответ не-принято (accepted=false / ok=false, план Task 12) + лог аудита (Ruling 13); недоступный сервис
/// догоняет упущенное realtime-sweep (контракт README).
/// <para>
/// Полная синхронизация каталога (применение entries к таблице Dialogs, ответ = список monitored id) —
/// модуль Deal.Modules.Telegram (план Task 13): DialogsService.SyncFromTelegram (upsert/удаление, авто-
/// мониторинг новых по autoMonitorNew), превью сообщений — DialogsService.SavePreview (PushMessage).
/// </para>
/// </remarks>
public sealed class TelegramIngressService(
IServiceScopeFactory scopeFactory,
SseBroker broker,
ILogger<TelegramIngressService> logger) : IngressService.IngressServiceBase
{
/// <summary>
/// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1).
/// </summary>
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,
};
/// <summary>
/// PushMessage — сообщение диалога в очередь пайплайна тенанта + превью (PushMessageRequest, Ruling 7).
/// </summary>
/// <remarks>Дубль dialog_id+msg_id уже в очереди — duplicate=true, очередь не растёт (гвард
/// PipelineIngestService). Пустой текст/диалог — no-op приёма (accepted=false, контракт proto).
/// После постановки в очередь пишется превью (DialogsService.SavePreview: строка TgMessages
/// «m_&lt;dialog&gt;_&lt;msg&gt;» + «последнее сообщение» каталога — 1:1 _on_message python L270274);
/// сбой превью не влияет на приём (accepted определён очередью, лог дебага).
/// Неизвестный тенант или сбой схемы/БД — не-принято (accepted=false) без исключения RPC.</remarks>
/// <param name="request">Сообщение из потока telegram-service.</param>
/// <param name="context">Контекст вызова (metadata tenant-id + service-token).</param>
/// <returns>accepted — сообщение принято (либо дубль), duplicate — уже было в очереди.</returns>
public override async Task<PushMessageReply> 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<ITenantContext>();
try
{
// Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService<PipelineIngestService>();
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();
}
}
/// <summary>
/// SyncDialogs — синхронизация каталога диалогов аккаунта (Ruling 7, L386390).
/// </summary>
/// <remarks>
/// Модуль Deal.Modules.Telegram (план Task 13) применяет entries к таблице Dialogs
/// (DialogsService.SyncFromTelegram: upsert + удаление отсутствующих; авто-мониторинг новых — по
/// настройке autoMonitorNew). Ответ несёт актуальный список monitored id — по нему telegram-service
/// держит своё зеркало мониторинга в памяти (обновляется ответом SyncDialogs и командой SetMonitor,
/// Ruling 7) и фильтрует события realtime.
/// </remarks>
/// <param name="request">Актуальный каталог диалогов (entries).</param>
/// <param name="context">Контекст вызова.</param>
/// <returns>monitored_ids — диалоги с включённым мониторингом после применения каталога.</returns>
public override async Task<SyncDialogsReply> 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<ITenantContext>();
try
{
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService<DialogsService>();
List<TelegramDialogEntryDto> 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<string> 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();
}
}
/// <summary>
/// ReportStatus — статус аккаунта в KV + SSE system_status/тосты на переходах фаз (Ruling 7).
/// </summary>
/// <remarks>KV tgStatus (снимок без account) и tgAccount (JSON-строка) пишутся в схему тенанта;
/// system_status публикуется на каждый репорт (фронт перечитывает /api/tg/status), тосты — только на
/// переходы connected: false→true «Telegram подключён, сессия сохранена», true→false «Telegram отключён»
/// (сервис шлёт статус по событию и heartbeat'ом — без гарда переходов тосты дублировались бы).
/// Неизвестный тенант/сбой схемы — ok=false без исключения RPC (план Task 12).</remarks>
/// <param name="request">Статус аккаунта из _publish_status прототипа.</param>
/// <param name="context">Контекст вызова.</param>
/// <returns>ok — статус принят и сохранён.</returns>
public override async Task<ReportStatusReply> 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<ITenantContext>();
try
{
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
ISettingsStore settings = tenantScope.ServiceProvider.GetRequiredService<ISettingsStore>();
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<TenantRecordDto?> 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<ITenantRepository>();
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<DialogsService>();
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<TgReportedStatus?> 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<TgReportedStatus>(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);
}
@@ -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;
+110 -108
View File
@@ -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;
/// <summary>
/// Сборка статуса вкладки Telegram — GET /api/tg/status (Ruling 8, api-map §4.9 L357359).
/// </summary>
/// <remarks>
/// Форма 1:1 с status() python L103119 в терминах этапа 6:
/// <list type="bullet">
/// <item>live-поля (phase/connected/listener/error/qrUrl) — из гейта <see cref="ITelegramGateway.StatusAsync"/>
/// (живой telegram-service); сервис недоступен/сессии нет (RPC-отказ) → idle-форма (Ruling 8);</item>
/// <item>account — из KV tgAccount (источник истины — ReportStatus ингресса, Ruling 7);</item>
/// <item>monitored — count(Dialogs WHERE Monitor) ядра (DialogsService.ListMonitoredIds);</item>
/// <item>keysSet — оба глобальных ключа приложения заданы оператором (TelegramKeysService, Ruling 3, ТЗ §4.1/§8.1).</item>
/// </list>
/// Scoped: зависимости живут на контекстах запроса (ISettingsStore — схема тенанта, IGlobalSettingsStore — public).
/// </remarks>
/// <param name="gateway">Порт-гейт telegram-service (живой статус аккаунта).</param>
/// <param name="dialogs">Сервис каталога диалогов ядра (счётчик мониторящихся).</param>
/// <param name="settings">KV-хранилище настроек тенанта (tgAccount).</param>
/// <param name="keys">Глобальные ключи приложения Telegram (keysSet).</param>
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,
};
/// <summary>
/// Форма GET /api/tg/status текущего тенанта (поля §4.9).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Полный статус вкладки Telegram.</returns>
public async Task<TgStatusDto> 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<TelegramAccountStatusDto> 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<string> 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;
/// <summary>
/// Сборка статуса вкладки Telegram — GET /api/tg/status (Ruling 8, api-map §4.9 L357359).
/// </summary>
/// <remarks>
/// Форма 1:1 с status() python L103119 в терминах этапа 6:
/// <list type="bullet">
/// <item>live-поля (phase/connected/listener/error/qrUrl) — из гейта <see cref="ITelegramGateway.StatusAsync"/>
/// (живой telegram-service); сервис недоступен/сессии нет (RPC-отказ) → idle-форма (Ruling 8);</item>
/// <item>account — из KV tgAccount (источник истины — ReportStatus ингресса, Ruling 7);</item>
/// <item>monitored — count(Dialogs WHERE Monitor) ядра (DialogsService.ListMonitoredIds);</item>
/// <item>keysSet — оба глобальных ключа приложения заданы оператором (TelegramKeysService, Ruling 3, ТЗ §4.1/§8.1).</item>
/// </list>
/// Scoped: зависимости живут на контекстах запроса (ISettingsStore — схема тенанта, IGlobalSettingsStore — public).
/// </remarks>
/// <param name="gateway">Порт-гейт telegram-service (живой статус аккаунта).</param>
/// <param name="dialogs">Сервис каталога диалогов ядра (счётчик мониторящихся).</param>
/// <param name="settings">KV-хранилище настроек тенанта (tgAccount).</param>
/// <param name="keys">Глобальные ключи приложения Telegram (keysSet).</param>
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,
};
/// <summary>
/// Форма GET /api/tg/status текущего тенанта (поля §4.9).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Полный статус вкладки Telegram.</returns>
public async Task<TgStatusDto> 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<TelegramAccountStatusDto> 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<string> 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;
}
}
}