using Deal.Api.Http;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
///
/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3).
///
///
/// Только под операторской сессией: без неё 401 «Требуется вход оператора» (как прочие /api/operator/*).
/// Ничего не меняет (read-only). groupBy — day|tenant|provider|model (неизвестное — 400 {detail}); from/to —
/// ISO-8601 (включительно), как у аудита; activity поддерживает фильтры eventType/actorType/actorId/tenantId,
/// limit (1..500) и offset. Все ответы — camelCase (контракт: docs/architecture/2026-09-10-operator-analytics-contract.md).
///
public static class OperatorAnalyticsEndpoints
{
// Префикс группы аналитики (Ruling 4 этапа 10).
private const string AnalyticsGroupPrefix = "/api/operator/analytics";
// OpenAPI-тег группы.
private const string OperatorOpenApiTag = "operator";
// Группировка расхода токенов по умолчанию (сутки).
private const string DefaultGroupBy = TokenUsageGroupBys.Day;
// 400 tokens: неизвестная группировка.
private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)";
///
/// Регистрирует группу /api/operator/analytics: overview/tokens/activity.
///
/// Построитель маршрутов приложения.
/// Построитель маршрутов для цепочки вызовов.
public static IEndpointRouteBuilder MapOperatorAnalyticsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(AnalyticsGroupPrefix).WithTags(OperatorOpenApiTag);
group.MapGet("/overview", OverviewAsync);
group.MapGet("/tokens", TokensAsync);
group.MapGet("/activity", ActivityAsync);
group.MapGet("/suspicious", SuspiciousAsync);
return app;
}
// GET /api/operator/analytics/suspicious?from=&to=: находки детектора подозрительной активности (§10.5).
// from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа.
// to: Конец окна анализа (включительно; ISO-8601); null — «сейчас».
// context: Контекст запроса.
// suspiciousService: Детектор подозрительной активности (scoped).
// ct: Токен отмены.
// Возвращает: 200 сводка находок или 401 без операторской сессии.
private static async Task SuspiciousAsync(
DateTimeOffset? from,
DateTimeOffset? to,
HttpContext context,
SuspiciousActivityService suspiciousService,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
SuspiciousActivityDto report = await suspiciousService.AnalyzeAsync(from, to, ct);
return Results.Ok(report);
}
// GET /api/operator/analytics/overview?from=&to=: сводка (тенанты, токены, события, входы/выходы).
// from: Начало периода (включительно; ISO-8601).
// to: Конец периода (включительно; ISO-8601).
// context: Контекст запроса.
// analyticsService: Сервис аналитики (scoped).
// ct: Токен отмены.
// Возвращает: 200 сводка или 401 без операторской сессии.
private static async Task OverviewAsync(
DateTimeOffset? from,
DateTimeOffset? to,
HttpContext context,
AnalyticsService analyticsService,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
AnalyticsOverviewDto overview = await analyticsService.OverviewAsync(from, to, ct);
return Results.Ok(overview);
}
// GET /api/operator/analytics/tokens?groupBy=&tenantId=&from=&to=: агрегаты расхода токенов.
// groupBy: Группировка day|tenant|provider|model (дефолт day).
// tenantId: Тенант (равенство; пусто — все тенанты).
// from: Начало периода (включительно; ISO-8601).
// to: Конец периода (включительно; ISO-8601).
// context: Контекст запроса.
// analyticsService: Сервис аналитики (scoped).
// ct: Токен отмены.
// Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии.
private static async Task TokensAsync(
string? groupBy,
Guid? tenantId,
DateTimeOffset? from,
DateTimeOffset? to,
HttpContext context,
AnalyticsService analyticsService,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
string normalizedGroupBy = string.IsNullOrWhiteSpace(groupBy) ? DefaultGroupBy : groupBy;
if (!IsKnownGroupBy(normalizedGroupBy))
{
return EndpointResults.BadRequest(InvalidGroupByDetail);
}
AnalyticsTokensDto tokens = await analyticsService.TokensAsync(normalizedGroupBy, tenantId, from, to, ct);
return Results.Ok(tokens);
}
// GET /api/operator/analytics/activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента действий.
// eventType: Тип события (равенство; пусто — без фильтра).
// actorType: Тип актора operator|tenant|system (равенство).
// actorId: Идентификатор актора (равенство).
// tenantId: Тенант (равенство).
// from: Нижняя граница At (включительно; ISO-8601).
// to: Верхняя граница At (включительно; ISO-8601).
// limit: Размер страницы (дефолт 100, кламп 1..500).
// offset: Смещение страницы (≥0).
// context: Контекст запроса.
// analyticsService: Сервис аналитики (scoped).
// ct: Токен отмены.
// Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии.
private static async Task ActivityAsync(
string? eventType,
string? actorType,
Guid? actorId,
Guid? tenantId,
DateTimeOffset? from,
DateTimeOffset? to,
int? limit,
int? offset,
HttpContext context,
AnalyticsService analyticsService,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
return Results.Ok(activity);
}
///
/// Известна ли группировка расхода токенов (day|tenant|provider|model).
///
/// Значение группировки.
/// True — поддерживаемая группировка.
public static bool IsKnownGroupBy(string groupBy) =>
groupBy is TokenUsageGroupBys.Day or TokenUsageGroupBys.Tenant
or TokenUsageGroupBys.Provider or TokenUsageGroupBys.Model;
}