Files
Deal/src/core/Deal.Modules.Tenants/Application/Services/AnalyticsService.cs
T
Rustam Khalimov 27c7831910
ci / build-test (push) Canceled after 0s
Deal — единая кодовая база
SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
2026-09-11 23:56:47 +03:00

161 lines
7.8 KiB
C#

using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис операторской аналитики
/// </summary>
public sealed class AnalyticsService(
ITenantRepository tenants,
AuditService audit,
TokenUsageEventService tokenUsage)
{
/// <summary>
/// Размер страницы ленты действий по умолчанию
/// </summary>
public const int DefaultActivityLimit = AuditService.DefaultQueryLimit;
/// <summary>
/// Верхняя граница размера страницы ленты действий
/// </summary>
public const int MaxActivityLimit = AuditService.MaxQueryLimit;
/// <summary>
/// Сводка: тенанты
/// </summary>
/// <param name="from">Начало периода (включительно; null — без границы).</param>
/// <param name="to">Конец периода (включительно; null — без границы).</param>
/// <returns>Сводка аналитики.</returns>
public async Task<AnalyticsOverviewDto> OverviewAsync(
DateTimeOffset? from,
DateTimeOffset? to,
CancellationToken ct)
{
IReadOnlyList<TenantRecordDto> allTenants = await tenants.ListAsync(ct);
int active = allTenants.Count(tenant => tenant.Status == TenantStatuses.Active);
// Токены за период — суммируем по тенантам (агрегат по тенантам: ключ не важен, берём суммы).
IReadOnlyList<TokenUsageAggregateDto> perTenant = await tokenUsage.AggregateAsync(
new TokenUsageEventQueryDto(null, null, null, null, from, to, TokenUsageGroupBys.Tenant), ct);
long promptTokens = perTenant.Sum(row => row.PromptTokens);
long completionTokens = perTenant.Sum(row => row.CompletionTokens);
long totalTokens = perTenant.Sum(row => row.TotalTokens);
long tokenEvents = perTenant.Sum(row => row.EventCount);
int events = await audit.CountAsync(
new AuditQueryDto(null, null, null, from, to, AuditService.DefaultQueryLimit), ct);
int logins = await CountEventTypesAsync(
from, to, [AuditEvents.TenantLoginOk, AuditEvents.OperatorLoginOk], ct);
int logouts = await CountEventTypesAsync(
from, to, [AuditEvents.TenantLogout, AuditEvents.OperatorLogout], ct);
int failedLogins = await CountEventTypesAsync(
from, to, [AuditEvents.TenantLoginFailed, AuditEvents.OperatorLoginFailed], ct);
return new AnalyticsOverviewDto(
TenantsTotal: allTenants.Count,
TenantsActive: active,
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TotalTokens: totalTokens,
TokenEvents: tokenEvents,
Events: events,
Logins: logins,
Logouts: logouts,
FailedLogins: failedLogins,
From: from,
To: to);
}
/// <summary>
/// Агрегаты расхода токенов по группировке и фильтрам
/// </summary>
/// <param name="groupBy">Группировка day|tenant|provider|model (валидирует HTTP-слой).</param>
/// <param name="tenantId">Тенант (равенство; null — все тенанты).</param>
/// <param name="from">Начало периода (включительно; null — без границы).</param>
/// <param name="to">Конец периода (включительно; null — без границы).</param>
/// <returns>Строки агрегатов и итог.</returns>
public async Task<AnalyticsTokensDto> TokensAsync(
string groupBy,
Guid? tenantId,
DateTimeOffset? from,
DateTimeOffset? to,
CancellationToken ct)
{
IReadOnlyList<TokenUsageAggregateDto> items = await tokenUsage.AggregateAsync(
new TokenUsageEventQueryDto(tenantId, null, null, null, from, to, groupBy), ct);
var total = new TokenUsageAggregateDto(
Key: "total",
PromptTokens: items.Sum(row => row.PromptTokens),
CompletionTokens: items.Sum(row => row.CompletionTokens),
TotalTokens: items.Sum(row => row.TotalTokens),
EventCount: items.Sum(row => row.EventCount));
return new AnalyticsTokensDto(groupBy, from, to, items, total);
}
/// <summary>
/// Лента действий
/// </summary>
/// <param name="eventType">Тип события (равенство; null — без фильтра).</param>
/// <param name="actorType">Тип актора operator|tenant|system (равенство; null — без фильтра).</param>
/// <param name="actorId">Идентификатор актора (равенство; null — без фильтра).</param>
/// <param name="tenantId">Тенант (равенство; null — без фильтра).</param>
/// <param name="from">Нижняя граница At (включительно; null — без границы).</param>
/// <param name="to">Верхняя граница At (включительно; null — без границы).</param>
/// <param name="limit">Размер страницы (дефолт 100, кламп 1..500).</param>
/// <param name="offset">Смещение страницы (≥0).</param>
/// <returns>Страница записей и полное число по фильтру.</returns>
public async Task<AnalyticsActivityDto> ActivityAsync(
string? eventType,
string? actorType,
Guid? actorId,
Guid? tenantId,
DateTimeOffset? from,
DateTimeOffset? to,
int? limit,
int? offset,
CancellationToken ct)
{
int normalizedLimit = NormalizeActivityLimit(limit);
int normalizedOffset = Math.Max(0, offset ?? 0);
var filter = new AuditQueryDto(
eventType, actorType, tenantId, from, to, normalizedLimit, actorId, normalizedOffset);
IReadOnlyList<AuditRecordDto> items = await audit.QueryAsync(filter, ct);
int total = await audit.CountAsync(filter, ct);
return new AnalyticsActivityDto(items, total, normalizedLimit, normalizedOffset);
}
/// <summary>
/// Нормализует limit ленты действий
/// </summary>
/// <param name="limit">Запрошенный размер (null — не задан).</param>
/// <returns>Значение для фильтра.</returns>
public static int NormalizeActivityLimit(int? limit) =>
limit is null
? DefaultActivityLimit
: Math.Max(1, Math.Min(MaxActivityLimit, limit.Value));
// Суммарное число записей аудита по списку типов событий за период.
// from: Нижняя граница At (включительно; null — без границы).
// to: Верхняя граница At (включительно; null — без границы).
// eventTypes: Типы событий (каждый считается отдельным равенством).
// ct: Токен отмены.
// Возвращает: Сумма по типам событий.
private async Task<int> CountEventTypesAsync(
DateTimeOffset? from,
DateTimeOffset? to,
string[] eventTypes,
CancellationToken ct)
{
int total = 0;
foreach (string eventType in eventTypes)
{
total += await audit.CountAsync(
new AuditQueryDto(eventType, null, null, from, to, AuditService.DefaultQueryLimit), ct);
}
return total;
}
}