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.
Результат¶
API отдаёт аналитическую сводку и CSV-файл. Эти функции доступны только ролям Admin и Analyst.
Проверка этапа:
- Сгенерировать не менее 100 событий.
- Войти под
analyst. - Выполнить
GET /api/analytics/summary. - Проверить, что
totalEventsбольше нуля. - Выполнить
GET /api/reports/events.csv. - Открыть CSV и убедиться, что строки содержат события и имена пользователей.
Если отчёт скачивается пустым, проверьте даты from и to. По умолчанию сервис берёт последние 7 дней, а генератор создаёт события в пределах этого периода.