Files
parr_api/PARR.Core/Services/RobotSnapshotServices/RobotSnapshotService.cs

387 lines
20 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using AutoMapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.RobotRepositories;
using PARR.Core.Services.Snapshots.Interfaces;
using PARR.Domain.DTOs.RobotSnapshotDTO;
using PARR.Domain.DTOs.User;
using PARR.Domain.Entities.RobotEntities;
using PARR.Domain.Exceptions;
namespace PARR.Core.Services.RobotSnapshotServices
{
internal class RobotSnapshotService : IRobotSnapshotService, ISnapshotProvider
{
private readonly IRobotSnapshotRepository _robotSnapshotRepository;
private readonly IUserRepository _userRepository;
private readonly IMapper _mapper;
private readonly ILogger<RobotSnapshotService> _logger;
private readonly ISnapshotSettings _snapshotSettings;
// Тут любое значение, не используем метод создания снапшота
public TimeSpan Interval => TimeSpan.FromHours(1);
public TimeSpan RetentionPeriod => _snapshotSettings.RobotSnapshotRetentionPeriod;
public RobotSnapshotService(
IRobotSnapshotRepository robotSnapshotRepository,
IUserRepository userRepository,
IMapper mapper,
ILogger<RobotSnapshotService> logger,
ISnapshotSettings snapshotSettings
)
{
_robotSnapshotRepository = robotSnapshotRepository;
_userRepository = userRepository;
_mapper = mapper;
_logger = logger;
_snapshotSettings = snapshotSettings;
}
public async Task<List<RobotSnapshotItemDto>> GetAsync(RobotSnapshotQuery queryDto)
{
// Просто возвращаем данные из БД, не проверяем что есть промежутки между которыми не было данных
// Т.е это просто чтоб посмотреть что есть в БД
var minutes = queryDto.Minutes > 0 ? queryDto.Minutes : 60 * 12;
var dateEnd = DateTimeOffset.UtcNow;
var dateStart = dateEnd.AddMinutes(-minutes);
var query = _robotSnapshotRepository.Get().AsNoTracking()
.Where(t => dateStart <= t.DateCreated && t.DateCreated <= dateEnd);
if (!string.IsNullOrEmpty(queryDto.Ip))
query = query.Where(t => t.Ip == queryDto.Ip);
var groupingSnapshots = (await query.ToListAsync()).GroupBy(t => t.Ip);
var robotIps = groupingSnapshots.Select(t => t.Key).ToHashSet();
var robotList = await _userRepository.Get()
.AsNoTracking()
.Where(t => robotIps.Contains(t.Ip))
.ToDictionaryAsync(t => t.Ip);
var result = new List<RobotSnapshotItemDto>();
foreach (var item in groupingSnapshots)
{
robotList.TryGetValue(item.Key, out var robot);
var userDto = new UserBaseDto { Ip = item.Key, Description = robot?.Description ?? string.Empty, Name = robot?.Name ?? string.Empty };
var snapshots = _mapper.Map<List<RobotSnapshotDto>>(item).OrderBy(t => t.DateCreated).ToList();
result.Add(new RobotSnapshotItemDto { Robot = userDto, Snapshots = snapshots });
}
return result.OrderBy(t => t.Robot.Name).ThenBy(t => t.Robot.Ip).ToList();
}
public async Task<List<ServerHourlyAnalyticsDto>> GetHourlyAnalyticsAsync(RobotAnalyticsQuery queryDto)
{
var snapshotsQuery = _robotSnapshotRepository.Get()
.AsNoTracking()
.Where(t => queryDto.DateStart <= t.DateCreated && t.DateCreated <= queryDto.DateEnd);
// Фильтруем по конкретному IP
if (!string.IsNullOrEmpty(queryDto.Ip))
snapshotsQuery = snapshotsQuery.Where(t => t.Ip == queryDto.Ip);
// Группируем на стороне PostgreSQL с учетом часового пояса пользователя.
// Метод .Add(offset) сдвигает UTC-дату в Postgres на интервал времени пользователя,
// благодаря чему минуты объединяются в правильные локальные "часовые корзины".
var rawGroupedData = await snapshotsQuery
.GroupBy(t => new
{
t.Ip,
// Явно сдвигаем дату и достаем только нужные компоненты
Year = (t.DateCreated + queryDto.Offset).DateTime.Year,
Month = (t.DateCreated + queryDto.Offset).DateTime.Month,
Day = (t.DateCreated + queryDto.Offset).DateTime.Day,
Hour = (t.DateCreated + queryDto.Offset).DateTime.Hour
})
.Select(g => new
{
g.Key.Ip,
// Вытаскиваем компоненты даты из уже смещенного локального времени
LocalYear = g.Key.Year,
LocalMonth = g.Key.Month,
LocalDay = g.Key.Day,
LocalHour = g.Key.Hour,
MaxAllowed = g.Max(t => t.MaxRobots),
MaxTemplates = g.Max(t => t.TemplateRobotsCount),
MaxSchedules = g.Max(t => t.ScheduleRobotsCount),
SumTemplates = g.Sum(t => t.TemplateRobotsCount),
SumSchedules = g.Sum(t => t.ScheduleRobotsCount)
})
.ToListAsync();
if (!rawGroupedData.Any())
return new List<ServerHourlyAnalyticsDto>();
// Получаем информацию о серверах
var serverIps = rawGroupedData.Select(g => g.Ip).ToHashSet();
var serverDictionary = await _userRepository.Get()
.AsNoTracking()
.Where(t => serverIps.Contains(t.Ip))
.ToDictionaryAsync(t => t.Ip);
// Группируем полученные агрегаты по серверам уже в памяти
var dataByServers = rawGroupedData.GroupBy(g => g.Ip);
var result = new List<ServerHourlyAnalyticsDto>();
// Принудительно переводим UTC-границы запроса в локальное время пользователя для корректной генерации "дыр"
var localStart = queryDto.DateStart.ToOffset(queryDto.Offset).DateTime;
var localEnd = queryDto.DateEnd.ToOffset(queryDto.Offset).DateTime;
// Округляем начальный час до ровного значения (00 минут, 00 секунд)
var startHour = new DateTime(localStart.Year, localStart.Month, localStart.Day, localStart.Hour, 0, 0);
foreach (var serverGroup in dataByServers)
{
serverDictionary.TryGetValue(serverGroup.Key, out var robotInfo);
var userDto = new UserBaseDto
{
Ip = serverGroup.Key,
Description = robotInfo?.Description ?? string.Empty,
Name = robotInfo?.Name ?? string.Empty
};
// Формируем точки, которые удалось вытащить из базы данных
var hourlyPoints = serverGroup.Select(g => new RobotHourlyPointDto
{
// Возвращаем дату со смещением пользователя, чтобы Angular сразу отображал правильный час
Hour = new DateTimeOffset(g.LocalYear, g.LocalMonth, g.LocalDay, g.LocalHour, 0, 0, TimeSpan.Zero),
MaxAllowedRobots = g.MaxAllowed,
MaxTemplateRobots = g.MaxTemplates,
MaxScheduleRobots = g.MaxSchedules,
// Считаем среднее количество роботов, деля поминутную сумму строго на 60 минут часа
//todo: тут не всегда нужно делить на 60, если час последний, то там может быть 50 минут или 20 и тп
AvgTemplateRobots = Math.Round(g.SumTemplates / 60.0, 1),
AvgScheduleRobots = Math.Round(g.SumSchedules / 60.0, 1)
})
.ToList();
// Алгоритм заполнения пропусков во времени (если сервер был выключен)
var filledPoints = new List<RobotHourlyPointDto>();
var currentHour = startHour;
int lastKnownMax = hourlyPoints.FirstOrDefault()?.MaxAllowedRobots ?? 0;
while (currentHour <= localEnd)
{
// Создаем временную точку со смещением для точного сопоставления
var targetOffsetDateTime = new DateTimeOffset(currentHour, TimeSpan.Zero);
var point = hourlyPoints.FirstOrDefault(p => p.Hour == targetOffsetDateTime);
if (point != null)
{
filledPoints.Add(point);
lastKnownMax = point.MaxAllowedRobots;
}
else
{
// Если за этот час записей в БД нет — значит, сервер был полностью оффлайн.
// Выводим нули, чтобы график в Angular не прерывался, а плавно падал.
filledPoints.Add(new RobotHourlyPointDto
{
Hour = targetOffsetDateTime,
MaxAllowedRobots = lastKnownMax,
MaxTemplateRobots = 0,
AvgTemplateRobots = 0,
MaxScheduleRobots = 0,
AvgScheduleRobots = 0
});
}
// Шагаем строго на +1 час вперед по локальному времени клиента
currentHour = currentHour.AddHours(1);
}
result.Add(new ServerHourlyAnalyticsDto
{
Robot = userDto,
Snapshots = filledPoints.OrderBy(p => p.Hour).ToList()
});
}
return result
.OrderBy(t => t.Robot.Name)
.ThenBy(t => t.Robot.Ip)
.ToList();
}
public async Task<List<HourlyRobotTypeAnalyticsDto>> GetHourlyAnalyticsByRobotTypeAsync(RobotAnalyticsRobotTypeQuery queryDto)
{
var snapshotsQuery = _robotSnapshotRepository.Get()
.AsNoTracking()
.Where(t => queryDto.DateStart <= t.DateCreated && t.DateCreated <= queryDto.DateEnd)
.GroupBy(t => new
{
// Явно сдвигаем дату
Year = (t.DateCreated + queryDto.Offset).DateTime.Year,
Month = (t.DateCreated + queryDto.Offset).DateTime.Month,
Day = (t.DateCreated + queryDto.Offset).DateTime.Day,
Hour = (t.DateCreated + queryDto.Offset).DateTime.Hour
});
IQueryable<RobotSnapshotRobotTypeDbDto> groupedQuery;
switch (queryDto.RobotType)
{
case Domain.Enums.RobotsEnum.TemplateOrder:
groupedQuery = snapshotsQuery.Select(g => new RobotSnapshotRobotTypeDbDto
{
// Вытаскиваем компоненты даты из уже смещенного локального времени
LocalYear = g.Key.Year,
LocalMonth = g.Key.Month,
LocalDay = g.Key.Day,
LocalHour = g.Key.Hour,
// Макс не правильно.
//MaxAllowed = g.Max(t => t.MaxRobots),
Sum = g.Sum(t => t.TemplateRobotsCount)
});
break;
case Domain.Enums.RobotsEnum.ScheduleOrder:
groupedQuery = snapshotsQuery.Select(g => new RobotSnapshotRobotTypeDbDto
{
// Вытаскиваем компоненты даты из уже смещенного локального времени
LocalYear = g.Key.Year,
LocalMonth = g.Key.Month,
LocalDay = g.Key.Day,
LocalHour = g.Key.Hour,
// Макс не правильно.
//MaxAllowed = g.Max(t => t.MaxRobots),
Sum = g.Sum(t => t.ScheduleRobotsCount)
});
break;
default:
throw new ArgumentException("Не обработано значение параметра", nameof(queryDto.RobotType));
}
var data = await groupedQuery.ToListAsync();
if (!data.Any())
return new List<HourlyRobotTypeAnalyticsDto>();
// Фиксируем текущее время в UTC и «округляем» его до начала часа
var nowLocal = DateTimeOffset.UtcNow.ToOffset(queryDto.Offset);
var currentHourLocal = new DateTime(nowLocal.Year, nowLocal.Month, nowLocal.Day, nowLocal.Hour, 0, 0);
var dbDataDict = data.ToDictionary(
k => new DateTime(k.LocalYear, k.LocalMonth, k.LocalDay, k.LocalHour, 0, 0),
v =>
{
double minutesInHour = 60.0;
var dbLocalHour = new DateTime(v.LocalYear, v.LocalMonth, v.LocalDay, v.LocalHour, 0, 0);
// Строгая проверка: совпадает ли час из базы данных ИМЕННО с текущим часом сегодня?
// Если да, то нужно делить не на 60, а на прошедние минуты
if (dbLocalHour == currentHourLocal)
{
// Если идет первая минута часа, делим на 1, чтобы избежать деления на ноль
minutesInHour = nowLocal.Minute == 0 ? 1.0 : (double)nowLocal.Minute;
}
return new
{
//MaxAllowedRobots = t.MaxAllowed,
AvgRobots = Math.Round(v.Sum / minutesInHour, 1)
};
});
// Заполняем модель
var statistics = new List<HourlyRobotTypeAnalyticsDto>();
#region Алгоритм заполнения пропусков во времени (если сервер был выключен)
// Превращаем границы в локальное время для генерации сетки часов
var currentHour = queryDto.DateStart.ToOffset(queryDto.Offset).DateTime;
var localEnd = queryDto.DateEnd.ToOffset(queryDto.Offset).DateTime;
// Срезаем минуты/секунды для старта
currentHour = new DateTime(currentHour.Year, currentHour.Month, currentHour.Day, currentHour.Hour, 0, 0);
while (currentHour <= localEnd)
{
// Ищем значение в словаре. Если нет — ставим 0
dbDataDict.TryGetValue(currentHour, out var statInfo);
// 1. Создаем DateTimeOffset для локального времени с правильным смещением
var localDateTimeOffset = new DateTimeOffset(currentHour, queryDto.Offset);
statistics.Add(new HourlyRobotTypeAnalyticsDto
{
// Возвращаем DateTimeOffset с нулевым смещением (как в вашем коде) или с queryDto.Offset
Hour = localDateTimeOffset.ToUniversalTime(),
AvgRobots = statInfo?.AvgRobots ?? 0
});
currentHour = currentHour.AddHours(1);
}
#endregion
return statistics;
}
public async Task<RobotSnapshotItemDto> CreateAsync(CreateRobotSnapshot robotSnapshot)
{
var snapshot = new RobotSnapshot
{
Id = Guid.NewGuid(),
TemplateRobotsCount = robotSnapshot.TemplateRobotsCount,
ScheduleRobotsCount = robotSnapshot.ScheduleRobotsCount,
MaxRobots = robotSnapshot.MaxRobots,
Ip = robotSnapshot.Ip
};
var createdResult = await _robotSnapshotRepository.CreateAsync(snapshot);
var commitResult = await _robotSnapshotRepository.CommitAsync();
if (!createdResult || !commitResult)
throw new DbErrorException("Ошибка сохранения в БД");
var user = await _userRepository.Get().AsNoTracking().FirstOrDefaultAsync(t => t.Ip == robotSnapshot.Ip);
var userDto = new UserBaseDto { Ip = robotSnapshot.Ip, Description = user?.Description ?? string.Empty, Name = user?.Name ?? string.Empty };
var result = new RobotSnapshotItemDto
{
Robot = userDto,
Snapshots = _mapper.Map<List<RobotSnapshotDto>>(new List<RobotSnapshot> { snapshot })
};
return result;
}
public Task TakeSnapshotAsync(CancellationToken cancellationToken)
{
// Метод пустой! Нам не нужно собирать данные по таймеру,
// так как они и так пишутся сюда через контроллер API.
return Task.CompletedTask;
}
public async Task CleanUpOldSnapshotsAsync(CancellationToken cancellationToken)
{
var thresholdDate = DateTimeOffset.UtcNow - RetentionPeriod;
_logger.LogInformation("[{ServiceName}] Запуск очистки старых снапшотов. Удаление данных старше {ThresholdDate}", GetType().Name, thresholdDate);
// Удаляем старые записи напрямую в PostgreSQL
var deletedCount = await _robotSnapshotRepository.Get()
.Where(s => s.DateCreated < thresholdDate)
.ExecuteDeleteAsync(cancellationToken);
_logger.LogInformation("[{ServiceName}] Очистка завершена. Удалено устаревших строк снапшотов: {Count}", GetType().Name, deletedCount);
}
}
}