Перейти к содержанию

7. Аналитический модуль и отчёты

Цель

Реализовать API аналитики и CSV-отчёт по событиям активности.

Объяснение

Аналитический модуль агрегирует данные: считает общее количество событий, уникальных пользователей, ошибки и распределение событий по типам.

CRUD показывает отдельные записи, но производственная ценность системы появляется в аналитике. Администратору редко нужно вручную просматривать тысячу строк событий. Ему важнее увидеть сводку: сколько всего действий, сколько ошибок, какие типы событий встречаются чаще всего.

Отчёт решает другую задачу: он выгружает данные наружу. CSV можно отправить преподавателю, открыть в Excel или использовать как доказательство выполненной практики.

Код: расширенный DTO аналитики

Сначала расширяется DTO аналитики. К уже существующим числовым показателям добавляется список популярных типов событий, потому что одной общей суммы недостаточно для понимания характера активности.

ActivityMonitoring.Application/Analytics/AnalyticsDtos.cs:

namespace ActivityMonitoring.Application.Analytics;

public record AnalyticsSummaryDto(
    int TotalEvents,
    int UniqueUsers,
    int Errors,
    IReadOnlyList<EventTypeCountDto> TopEventTypes);

public record EventTypeCountDto(string Type, int Count);

Сервис аналитики строит агрегаты по выбранному периоду. Здесь важно увидеть отличие от обычного списка событий: вместо возврата строк из таблицы код группирует данные, считает ошибки и выбирает самые частые типы событий.

ActivityMonitoring.Infrastructure/Analytics/AnalyticsService.cs:

using ActivityMonitoring.Application.Analytics;
using ActivityMonitoring.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;

namespace ActivityMonitoring.Infrastructure.Analytics;

public class AnalyticsService
{
    private readonly AppDbContext _db;
    private readonly IMemoryCache _cache;

    public AnalyticsService(AppDbContext db, IMemoryCache cache)
    {
        _db = db;
        _cache = cache;
    }

    public async Task<AnalyticsSummaryDto> GetSummaryAsync(DateTime? from, DateTime? to, CancellationToken ct)
    {
        var start = from ?? DateTime.UtcNow.AddDays(-7);
        var end = to ?? DateTime.UtcNow;
        var key = $"summary:{start:yyyyMMddHHmm}:{end:yyyyMMddHHmm}";

        return await _cache.GetOrCreateAsync(key, async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2);

            var query = _db.ActivityEvents
                .AsNoTracking()
                .Where(x => x.CreatedAt >= start && x.CreatedAt <= end);

            var topTypes = await query
                .GroupBy(x => x.Type)
                .Select(g => new EventTypeCountDto(g.Key, g.Count()))
                .OrderByDescending(x => x.Count)
                .Take(5)
                .ToListAsync(ct);

            return new AnalyticsSummaryDto(
                TotalEvents: await query.CountAsync(ct),
                UniqueUsers: await query.Select(x => x.UserId).Distinct().CountAsync(ct),
                Errors: await query.CountAsync(x => x.Severity == "Error", ct),
                TopEventTypes: topTypes);
        }) ?? new AnalyticsSummaryDto(0, 0, 0, []);
    }
}

Контроллер аналитики публикует сводку как защищённый endpoint. Доступ получают только Admin и Analyst, потому что аналитические данные обычно не нужны оператору, который только создаёт события.

ActivityMonitoring.Api/Controllers/AnalyticsController.cs:

using ActivityMonitoring.Application.Analytics;
using ActivityMonitoring.Infrastructure.Analytics;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace ActivityMonitoring.Api.Controllers;

[ApiController]
[Route("api/analytics")]
public class AnalyticsController : ControllerBase
{
    private readonly AnalyticsService _analyticsService;

    public AnalyticsController(AnalyticsService analyticsService)
    {
        _analyticsService = analyticsService;
    }

    [Authorize(Roles = "Admin,Analyst")]
    [HttpGet("summary")]
    public async Task<ActionResult<AnalyticsSummaryDto>> Summary(
        DateTime? from,
        DateTime? to,
        CancellationToken ct)
    {
        return Ok(await _analyticsService.GetSummaryAsync(from, to, ct));
    }
}

Код: CSV-отчёт

Для отчёта нужен отдельный DTO строки. Он описывает не объект из базы целиком, а именно те колонки, которые должны попасть в CSV-файл и быть понятны человеку при открытии отчёта.

ActivityMonitoring.Application/Reports/ReportDtos.cs:

namespace ActivityMonitoring.Application.Reports;

public record ActivityEventReportRow(
    int Id,
    string UserName,
    string Type,
    string Source,
    string Severity,
    DateTime CreatedAt,
    string Description);

Генератор CSV превращает набор строк отчёта в байты файла. Отдельный класс удобен тем, что правила форматирования CSV не смешиваются с запросами к базе и HTTP-ответами.

ActivityMonitoring.Infrastructure/Reports/CsvReportGenerator.cs:

using System.Text;
using ActivityMonitoring.Application.Reports;

namespace ActivityMonitoring.Infrastructure.Reports;

public class CsvReportGenerator
{
    public byte[] GenerateEventsReport(IEnumerable<ActivityEventReportRow> rows)
    {
        var builder = new StringBuilder();
        builder.AppendLine("Id,UserName,Type,Source,Severity,CreatedAt,Description");

        foreach (var row in rows)
        {
            builder.Append(row.Id).Append(',');
            builder.Append(Escape(row.UserName)).Append(',');
            builder.Append(Escape(row.Type)).Append(',');
            builder.Append(Escape(row.Source)).Append(',');
            builder.Append(Escape(row.Severity)).Append(',');
            builder.Append(row.CreatedAt.ToString("O")).Append(',');
            builder.AppendLine(Escape(row.Description));
        }

        return Encoding.UTF8.GetBytes(builder.ToString());
    }

    private static string Escape(string value)
    {
        if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
            return $"\"{value.Replace("\"", "\"\"")}\"";

        return value;
    }
}

Сервис отчётов выбирает данные из базы, добавляет имя пользователя через Include и передаёт подготовленные строки генератору CSV. Он соединяет две задачи: получение данных и формирование выгрузки.

ActivityMonitoring.Infrastructure/Reports/ReportsService.cs:

using ActivityMonitoring.Application.Reports;
using ActivityMonitoring.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;

namespace ActivityMonitoring.Infrastructure.Reports;

public class ReportsService
{
    private readonly AppDbContext _db;
    private readonly CsvReportGenerator _csv;

    public ReportsService(AppDbContext db, CsvReportGenerator csv)
    {
        _db = db;
        _csv = csv;
    }

    public async Task<byte[]> BuildEventsCsvAsync(DateTime? from, DateTime? to, CancellationToken ct)
    {
        var start = from ?? DateTime.UtcNow.AddDays(-7);
        var end = to ?? DateTime.UtcNow;

        var rows = await _db.ActivityEvents
            .AsNoTracking()
            .Include(x => x.User)
            .Where(x => x.CreatedAt >= start && x.CreatedAt <= end)
            .OrderByDescending(x => x.CreatedAt)
            .Select(x => new ActivityEventReportRow(
                x.Id,
                x.User.UserName,
                x.Type,
                x.Source,
                x.Severity,
                x.CreatedAt,
                x.Description))
            .ToListAsync(ct);

        return _csv.GenerateEventsReport(rows);
    }
}

Контроллер отчётов возвращает готовый CSV как файл. Это отдельный endpoint, потому что результатом здесь является не JSON, а скачиваемая выгрузка с нужным MIME-типом и именем файла.

ActivityMonitoring.Api/Controllers/ReportsController.cs:

using ActivityMonitoring.Infrastructure.Reports;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace ActivityMonitoring.Api.Controllers;

[ApiController]
[Route("api/reports")]
public class ReportsController : ControllerBase
{
    private readonly ReportsService _reportsService;

    public ReportsController(ReportsService reportsService)
    {
        _reportsService = reportsService;
    }

    [Authorize(Roles = "Admin,Analyst")]
    [HttpGet("events.csv")]
    public async Task<IActionResult> EventsCsv(DateTime? from, DateTime? to, CancellationToken ct)
    {
        var bytes = await _reportsService.BuildEventsCsvAsync(from, to, ct);
        return File(bytes, "text/csv; charset=utf-8", "activity-events.csv");
    }
}

Зарегистрируйте сервис отчётов и генератор CSV. ReportsService зависит от базы и создаётся на запрос, а CsvReportGenerator не хранит состояние, поэтому его можно использовать как singleton.

builder.Services.AddScoped<ReportsService>();
builder.Services.AddSingleton<CsvReportGenerator>();

Результат

API отдаёт аналитическую сводку и CSV-файл. Эти функции доступны только ролям Admin и Analyst.

Проверка этапа:

  1. Сгенерировать не менее 100 событий.
  2. Войти под analyst.
  3. Выполнить GET /api/analytics/summary.
  4. Проверить, что totalEvents больше нуля.
  5. Выполнить GET /api/reports/events.csv.
  6. Открыть CSV и убедиться, что строки содержат события и имена пользователей.

Если отчёт скачивается пустым, проверьте даты from и to. По умолчанию сервис берёт последние 7 дней, а генератор создаёт события в пределах этого периода.