Разбить модули Deal.Modules.* по назначению
Application проектов Discovery, Kanban, Pipeline, Settings, Tenants разделён на Abstractions/Exceptions/Extensions/Models/Registrars/Services; namespace приведён к путям, using потребителей мигрированы и дедуплицированы (169 файлов), cref/FQN обновлены.
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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=&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=&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=&tenantId=&from=&to=: агрегаты расхода токенов.
|
||||
// groupBy: Группировка day|tenant|provider|model (дефолт day).
|
||||
// tenantId: Тенант (равенство; пусто — все тенанты).
|
||||
// from: Начало периода (включительно; ISO-8601).
|
||||
// to: Конец периода (включительно; ISO-8601).
|
||||
// context: Контекст запроса.
|
||||
// analyticsService: Сервис аналитики (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии.
|
||||
private static async Task<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=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента действий.
|
||||
// eventType: Тип события (равенство; пусто — без фильтра).
|
||||
// actorType: Тип актора operator|tenant|system (равенство).
|
||||
// actorId: Идентификатор актора (равенство).
|
||||
// tenantId: Тенант (равенство).
|
||||
// from: Нижняя граница At (включительно; ISO-8601).
|
||||
// to: Верхняя граница At (включительно; ISO-8601).
|
||||
// limit: Размер страницы (дефолт 100, кламп 1..500).
|
||||
// offset: Смещение страницы (≥0).
|
||||
// context: Контекст запроса.
|
||||
// analyticsService: Сервис аналитики (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии.
|
||||
private static async Task<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=&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=&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=&tenantId=&from=&to=: агрегаты расхода токенов.
|
||||
// groupBy: Группировка day|tenant|provider|model (дефолт day).
|
||||
// tenantId: Тенант (равенство; пусто — все тенанты).
|
||||
// from: Начало периода (включительно; ISO-8601).
|
||||
// to: Конец периода (включительно; ISO-8601).
|
||||
// context: Контекст запроса.
|
||||
// analyticsService: Сервис аналитики (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии.
|
||||
private static async Task<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=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента действий.
|
||||
// eventType: Тип события (равенство; пусто — без фильтра).
|
||||
// actorType: Тип актора operator|tenant|system (равенство).
|
||||
// actorId: Идентификатор актора (равенство).
|
||||
// tenantId: Тенант (равенство).
|
||||
// from: Нижняя граница At (включительно; ISO-8601).
|
||||
// to: Верхняя граница At (включительно; ISO-8601).
|
||||
// limit: Размер страницы (дефолт 100, кламп 1..500).
|
||||
// offset: Смещение страницы (≥0).
|
||||
// context: Контекст запроса.
|
||||
// analyticsService: Сервис аналитики (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии.
|
||||
private static async Task<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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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_<N>).
|
||||
// ct: Токен отмены прохода.
|
||||
// Возвращает: Пара (глубина очереди пайплайна, глубина MlOutbox) для тенанта; сбой — (0, 0).
|
||||
private async Task<(int Queue, int Outbox)> CollectTenantAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<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_<N>).
|
||||
// ct: Токен отмены прохода.
|
||||
// Возвращает: Пара (глубина очереди пайплайна, глубина MlOutbox) для тенанта; сбой — (0, 0).
|
||||
private async Task<(int Queue, int Outbox)> CollectTenantAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+760
-742
File diff suppressed because it is too large
Load Diff
@@ -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, L361–377; Ruling 1/7).
|
||||
///
|
||||
/// Реализация серверной стороны Deal.Grpc.Telegram.IngressService (telegram.proto, L380–395):
|
||||
/// 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, L169–213): 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_<dialog>_<msg>» + «последнее сообщение» каталога — 1:1 _on_message python L270–274);
|
||||
/// сбой превью не влияет на приём (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, L386–390).
|
||||
/// </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, L361–377; Ruling 1/7).
|
||||
///
|
||||
/// Реализация серверной стороны Deal.Grpc.Telegram.IngressService (telegram.proto, L380–395):
|
||||
/// 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, L169–213): 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_<dialog>_<msg>» + «последнее сообщение» каталога — 1:1 _on_message python L270–274);
|
||||
/// сбой превью не влияет на приём (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, L386–390).
|
||||
/// </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;
|
||||
|
||||
|
||||
@@ -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 L357–359).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Форма 1:1 с status() python L103–119 в терминах этапа 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 L357–359).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Форма 1:1 с status() python L103–119 в терминах этапа 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user