ci / build-test (push) Canceled after 0s
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 зелёные.
161 lines
7.8 KiB
C#
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;
|
|
}
|
|
}
|