269 lines
18 KiB
C#
269 lines
18 KiB
C#
using DailyDigestWorker.Configuration; // Наша конфигурация
|
||
using DailyDigestWorker.Services; // Наши сервисы
|
||
using Microsoft.Extensions.Options; // Для IOptions
|
||
using System.Globalization; // Для TimeSpan.ParseExact и других настроек культуры
|
||
using Telegram.Bot.Types.Enums; // Для ParseMode
|
||
|
||
namespace DailyDigestWorker
|
||
{
|
||
public class Worker : BackgroundService
|
||
{
|
||
private readonly ILogger<Worker> _logger;
|
||
// Список времен запуска в формате TimeSpan (представляют ЛОКАЛЬНОЕ время ЦЕЛЕВОГО часового пояса)
|
||
private readonly List<TimeSpan> _runTimesLocal;
|
||
private readonly TimeZoneInfo _targetTimeZone; // Целевой часовой пояс пользователя
|
||
private readonly IServiceProvider _serviceProvider;
|
||
|
||
public Worker(ILogger<Worker> logger,
|
||
IOptions<SchedulingSettings> schedulingSettings, // Используем обновленный SchedulingSettings
|
||
IServiceProvider serviceProvider)
|
||
{
|
||
_logger = logger;
|
||
_serviceProvider = serviceProvider;
|
||
_runTimesLocal = new List<TimeSpan>(); // Инициализируем список
|
||
|
||
// Получаем настройки планировщика (или используем дефолтные, если секция отсутствует)
|
||
var settings = schedulingSettings.Value ?? new SchedulingSettings();
|
||
var timeStrings = settings.RunAtTimesLocal; // Список строк времени
|
||
string targetTimeZoneId = settings.TargetTimeZoneId; // ID часового пояса
|
||
|
||
// 1. Определяем целевой часовой пояс
|
||
try
|
||
{
|
||
_targetTimeZone = TimeZoneInfo.FindSystemTimeZoneById(targetTimeZoneId);
|
||
_logger.LogInformation("Целевой часовой пояс установлен: {TimeZoneId} ({DisplayName})", _targetTimeZone.Id, _targetTimeZone.DisplayName);
|
||
}
|
||
catch (TimeZoneNotFoundException)
|
||
{
|
||
_logger.LogError("Часовой пояс с ID '{TimeZoneId}' не найден в системе! Будет использовано локальное время сервера (TimeZoneInfo.Local).", targetTimeZoneId);
|
||
_targetTimeZone = TimeZoneInfo.Local; // Откат к локальному времени сервера
|
||
}
|
||
catch (InvalidTimeZoneException ex)
|
||
{
|
||
_logger.LogError(ex, "Ошибка при загрузке данных часового пояса '{TimeZoneId}'. Будет использовано локальное время сервера.", targetTimeZoneId);
|
||
_targetTimeZone = TimeZoneInfo.Local;
|
||
}
|
||
catch (Exception ex) // Ловим другие возможные ошибки при работе с TimeZoneInfo
|
||
{
|
||
_logger.LogError(ex, "Неожиданная ошибка при определении часового пояса '{TimeZoneId}'. Будет использовано локальное время сервера.", targetTimeZoneId);
|
||
_targetTimeZone = TimeZoneInfo.Local;
|
||
}
|
||
|
||
|
||
// 2. Парсим ВРЕМЕНА ЗАПУСКА (они всегда в целевом часовом поясе)
|
||
if (timeStrings != null)
|
||
{
|
||
foreach (var timeStr in timeStrings)
|
||
{
|
||
// Используем ParseExact для строгого формата "HH:mm:ss"
|
||
if (TimeSpan.TryParseExact(timeStr?.Trim(), @"hh\:mm\:ss", CultureInfo.InvariantCulture, out var timeSpan))
|
||
{
|
||
_runTimesLocal.Add(timeSpan);
|
||
}
|
||
else
|
||
{
|
||
_logger.LogWarning("Не удалось распознать время '{TimeString}' в настройках Scheduling:RunAtTimesLocal. Используйте формат HH:mm:ss.", timeStr);
|
||
}
|
||
}
|
||
// Убираем дубликаты и сортируем
|
||
_runTimesLocal = _runTimesLocal.Distinct().OrderBy(t => t).ToList();
|
||
}
|
||
|
||
// 3. Проверка и логирование времен запуска
|
||
if (!_runTimesLocal.Any())
|
||
{
|
||
_logger.LogError("В конфигурации не указано ни одного корректного времени запуска (RunAtTimesLocal)! Worker не сможет автоматически запускаться.");
|
||
// Здесь НЕТ добавления времени по умолчанию, чтобы требовать явную конфигурацию
|
||
}
|
||
|
||
_logger.LogInformation("Worker инициализирован. Дайджест будет запускаться ежедневно в (по времени {TargetTimeZoneId}): {RunTimes}",
|
||
_targetTimeZone.Id,
|
||
_runTimesLocal.Any() ? string.Join(", ", _runTimesLocal.Select(t => t.ToString(@"hh\:mm\:ss"))) : "НЕТ ВРЕМЕН ЗАПУСКА");
|
||
}
|
||
|
||
// Основной метод, который выполняется в фоне
|
||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||
{
|
||
_logger.LogInformation("Worker запущен в: {time} UTC", DateTimeOffset.UtcNow); // Логгируем UTC время запуска
|
||
|
||
if (!_runTimesLocal.Any())
|
||
{
|
||
_logger.LogError("Нет сконфигурированных времен запуска. Worker завершает работу.");
|
||
return; // Останавливаем выполнение, если нет времен
|
||
}
|
||
|
||
while (!stoppingToken.IsCancellationRequested)
|
||
{
|
||
// Рассчитываем задержку до ближайшего времени запуска в целевом часовом поясе
|
||
TimeSpan delay = CalculateDelayUntilNearestRunInTargetZone(_runTimesLocal, _targetTimeZone);
|
||
|
||
// Логируем время следующего запуска в UTC и в целевой зоне
|
||
var nextRunUtc = DateTime.UtcNow.Add(delay);
|
||
var nextRunTargetZone = TimeZoneInfo.ConvertTimeFromUtc(nextRunUtc, _targetTimeZone);
|
||
|
||
_logger.LogInformation("Следующий запуск дайджеста запланирован через: {Delay} (в {TargetTimeLocal} {TargetZoneId})",
|
||
delay, nextRunTargetZone.ToString("yyyy-MM-dd HH:mm:ss"), _targetTimeZone.Id);
|
||
|
||
try
|
||
{
|
||
// Ожидаем до следующего времени запуска
|
||
await Task.Delay(delay, stoppingToken);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
_logger.LogInformation("Запрос на остановку получен во время ожидания.");
|
||
break; // Выходим из цикла while
|
||
}
|
||
|
||
if (stoppingToken.IsCancellationRequested) break; // Проверяем еще раз
|
||
|
||
// --- Выполнение основной задачи ---
|
||
_logger.LogInformation("Запуск процесса создания дайджеста в: {time} UTC", DateTimeOffset.UtcNow);
|
||
using (var scope = _serviceProvider.CreateScope())
|
||
{
|
||
// --- Получаем сервисы ---
|
||
var currencyService = scope.ServiceProvider.GetRequiredService<ICurrencyService>();
|
||
var cryptoService = scope.ServiceProvider.GetRequiredService<ICryptoService>();
|
||
var weatherService = scope.ServiceProvider.GetRequiredService<IWeatherService>();
|
||
var digestBuilder = scope.ServiceProvider.GetRequiredService<IDigestBuilderService>();
|
||
var telegramSender = scope.ServiceProvider.GetRequiredService<ITelegramBotSender>();
|
||
var newsReaderService = scope.ServiceProvider.GetRequiredService<ITelegramChannelReader>();
|
||
var summarizationService = scope.ServiceProvider.GetRequiredService<ISummarizationService>();
|
||
|
||
const int NewsMaxAgeHours = 24;
|
||
const int NewsFetchLimit = 50;
|
||
string? newsSummary = null;
|
||
|
||
try
|
||
{
|
||
// --- 1. Сбор данных ---
|
||
_logger.LogInformation("Начало сбора данных для дайджеста...");
|
||
var currencyTask = currencyService.GetCurrencyRatesAsync(stoppingToken);
|
||
var cryptoTask = cryptoService.GetCryptoRatesAsync(stoppingToken);
|
||
var weatherTask = weatherService.GetWeatherAsync(stoppingToken);
|
||
var newsTask = newsReaderService.GetRecentNewsAsync(NewsMaxAgeHours, NewsFetchLimit, stoppingToken);
|
||
|
||
await Task.WhenAll(currencyTask, cryptoTask, weatherTask, newsTask);
|
||
|
||
var currencyData = await currencyTask;
|
||
var cryptoData = await cryptoTask;
|
||
var weatherData = await weatherTask;
|
||
var rawNewsTexts = await newsTask;
|
||
_logger.LogInformation("Сбор всех базовых данных завершен.");
|
||
|
||
// --- 1.5 Генерация саммари ---
|
||
if (rawNewsTexts != null && rawNewsTexts.Any())
|
||
{
|
||
_logger.LogInformation("Получено {NewsCount} текстов новостей. Запуск суммаризации...", rawNewsTexts.Count);
|
||
try
|
||
{
|
||
string combinedNewsText = string.Join("\n\n---\n\n", rawNewsTexts);
|
||
newsSummary = await summarizationService.SummarizeTextAsync(combinedNewsText, stoppingToken);
|
||
if (newsSummary != null)
|
||
{
|
||
_logger.LogInformation("Саммари новостей успешно получено.");
|
||
_logger.LogDebug("Текст саммари:\n{NewsSummary}", newsSummary);
|
||
}
|
||
else
|
||
{
|
||
_logger.LogWarning("Сервис суммаризации вернул null.");
|
||
}
|
||
}
|
||
catch (OperationCanceledException) { _logger.LogInformation("Операция суммаризации была отменена."); }
|
||
catch (Exception ex) { _logger.LogError(ex, "Ошибка во время вызова сервиса суммаризации."); }
|
||
}
|
||
else
|
||
{
|
||
_logger.LogInformation("Нет текстов новостей для суммаризации.");
|
||
}
|
||
|
||
// --- 2. Формирование текста ---
|
||
_logger.LogInformation("Формирование текста дайджеста...");
|
||
string digestText = digestBuilder.BuildDigestText(currencyData, cryptoData, weatherData, newsSummary);
|
||
_logger.LogDebug("Сформированный текст получен от DigestBuilder.");
|
||
|
||
// --- 3. Отправка ---
|
||
if (!string.IsNullOrWhiteSpace(digestText))
|
||
{
|
||
_logger.LogInformation("Отправка дайджеста...");
|
||
bool sent = await telegramSender.SendMessageAsync(digestText, ParseMode.Markdown, stoppingToken);
|
||
if (!sent) { _logger.LogWarning("Не удалось инициировать отправку дайджеста."); }
|
||
}
|
||
else { _logger.LogWarning("Текст дайджеста пуст, отправка не производится."); }
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
_logger.LogInformation("Запрос на остановку получен во время выполнения основной задачи.");
|
||
break;
|
||
}
|
||
catch (Exception ex)
|
||
{ // Ловим КРИТИЧЕСКИЕ ошибки цикла задачи
|
||
_logger.LogError(ex, "Произошла КРИТИЧЕСКАЯ ошибка во время выполнения задачи создания дайджеста.");
|
||
try { await telegramSender.SendErrorMessageAsync($"Критическая ошибка в Worker: {ex.Message}", CancellationToken.None); }
|
||
catch (Exception sendEx) { _logger.LogError(sendEx, "Не удалось отправить сообщение о КРИТИЧЕСКОЙ ошибке."); }
|
||
// Не делаем Delay, чтобы быстрее перейти к следующему плановому запуску
|
||
}
|
||
} // Конец scope
|
||
|
||
_logger.LogInformation("Процесс создания дайджеста завершен. Расчет следующего запуска.");
|
||
|
||
// Короткая пауза после выполнения, чтобы не запустить случайно дважды в одну секунду
|
||
try { await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); }
|
||
catch (OperationCanceledException) { break; }
|
||
|
||
} // Конец while
|
||
|
||
_logger.LogInformation("Worker останавливается.");
|
||
}
|
||
|
||
// Метод для расчета задержки до ближайшего времени запуска в целевом часовом поясе
|
||
private TimeSpan CalculateDelayUntilNearestRunInTargetZone(List<TimeSpan> runTimesLocal, TimeZoneInfo targetZone)
|
||
{
|
||
// Получаем ТЕКУЩЕЕ время в ЦЕЛЕВОМ часовом поясе
|
||
DateTime targetZoneNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, targetZone);
|
||
DateTime targetZoneToday = targetZoneNow.Date;
|
||
TimeSpan targetZoneCurrentTimeOfDay = targetZoneNow.TimeOfDay;
|
||
|
||
_logger.LogDebug("Текущее время в целевой зоне ({TargetZoneId}): {TargetNow:yyyy-MM-dd HH:mm:ss}", targetZone.Id, targetZoneNow);
|
||
|
||
// Ищем ближайшее время запуска СЕГОДНЯ (в целевой зоне), которое >= текущего времени
|
||
TimeSpan? nextRunTimeToday = runTimesLocal
|
||
.Where(t => t >= targetZoneCurrentTimeOfDay)
|
||
.Cast<TimeSpan?>()
|
||
.FirstOrDefault(); // Используем FirstOrDefault, т.к. список отсортирован
|
||
|
||
DateTime nextRunTargetZoneDateTime; // Время следующего запуска в целевой зоне
|
||
|
||
if (nextRunTimeToday.HasValue)
|
||
{
|
||
// Планируем на сегодня (в целевой зоне)
|
||
nextRunTargetZoneDateTime = targetZoneToday + nextRunTimeToday.Value;
|
||
_logger.LogDebug("Ближайшее время запуска сегодня (в целевой зоне): {RunTime:yyyy-MM-dd HH:mm:ss}", nextRunTargetZoneDateTime);
|
||
}
|
||
else
|
||
{
|
||
// Планируем на ЗАВТРА (в целевой зоне), используя самое раннее время
|
||
var earliestTime = runTimesLocal.First(); // Гарантированно есть и отсортировано
|
||
nextRunTargetZoneDateTime = targetZoneToday.AddDays(1) + earliestTime;
|
||
_logger.LogDebug("Все времена запуска сегодня (в целевой зоне) прошли. Ближайшее время завтра: {RunTime:yyyy-MM-dd HH:mm:ss}", nextRunTargetZoneDateTime);
|
||
}
|
||
|
||
// Переводим рассчитанное время следующего запуска из целевой зоны обратно в UTC
|
||
DateTime nextRunUtc = TimeZoneInfo.ConvertTimeToUtc(nextRunTargetZoneDateTime, targetZone);
|
||
|
||
// Рассчитываем задержку от ТЕКУЩЕГО времени UTC
|
||
TimeSpan delay = nextRunUtc - DateTime.UtcNow;
|
||
|
||
_logger.LogDebug("Рассчитанное время следующего запуска UTC: {NextRunUtc:yyyy-MM-dd HH:mm:ss}Z", nextRunUtc);
|
||
|
||
// Если задержка отрицательная (уже опоздали), возвращаем Zero для немедленного запуска.
|
||
return delay < TimeSpan.Zero ? TimeSpan.Zero : delay;
|
||
}
|
||
|
||
public override Task StopAsync(CancellationToken cancellationToken)
|
||
{
|
||
_logger.LogInformation("Worker StopAsync вызван.");
|
||
return base.StopAsync(cancellationToken);
|
||
}
|
||
}
|
||
} |