Application проектов Discovery, Kanban, Pipeline, Settings, Tenants разделён на Abstractions/Exceptions/Extensions/Models/Registrars/Services; namespace приведён к путям, using потребителей мигрированы и дедуплицированы (169 файлов), cref/FQN обновлены.
172 lines
8.5 KiB
C#
172 lines
8.5 KiB
C#
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;
|
|
}
|