using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Models; namespace Deal.Modules.Tenants.Application.Services; /// /// Прикладной сервис операторской аналитики /// public sealed class AnalyticsService( ITenantRepository tenants, AuditService audit, TokenUsageEventService tokenUsage) { /// /// Размер страницы ленты действий по умолчанию /// public const int DefaultActivityLimit = AuditService.DefaultQueryLimit; /// /// Верхняя граница размера страницы ленты действий /// public const int MaxActivityLimit = AuditService.MaxQueryLimit; /// /// Сводка: тенанты /// /// Начало периода (включительно; null — без границы). /// Конец периода (включительно; null — без границы). /// Сводка аналитики. public async Task OverviewAsync( DateTimeOffset? from, DateTimeOffset? to, CancellationToken ct) { IReadOnlyList allTenants = await tenants.ListAsync(ct); int active = allTenants.Count(tenant => tenant.Status == TenantStatuses.Active); // Токены за период — суммируем по тенантам (агрегат по тенантам: ключ не важен, берём суммы). IReadOnlyList 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); } /// /// Агрегаты расхода токенов по группировке и фильтрам /// /// Группировка day|tenant|provider|model (валидирует HTTP-слой). /// Тенант (равенство; null — все тенанты). /// Начало периода (включительно; null — без границы). /// Конец периода (включительно; null — без границы). /// Строки агрегатов и итог. public async Task TokensAsync( string groupBy, Guid? tenantId, DateTimeOffset? from, DateTimeOffset? to, CancellationToken ct) { IReadOnlyList 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); } /// /// Лента действий /// /// Тип события (равенство; null — без фильтра). /// Тип актора operator|tenant|system (равенство; null — без фильтра). /// Идентификатор актора (равенство; null — без фильтра). /// Тенант (равенство; null — без фильтра). /// Нижняя граница At (включительно; null — без границы). /// Верхняя граница At (включительно; null — без границы). /// Размер страницы (дефолт 100, кламп 1..500). /// Смещение страницы (≥0). /// Страница записей и полное число по фильтру. public async Task 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 items = await audit.QueryAsync(filter, ct); int total = await audit.CountAsync(filter, ct); return new AnalyticsActivityDto(items, total, normalizedLimit, normalizedOffset); } /// /// Нормализует limit ленты действий /// /// Запрошенный размер (null — не задан). /// Значение для фильтра. 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 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; } }