daily_digest/Worker.cs
2025-04-12 01:35:40 +07:00

269 lines
18 KiB
C#
Raw 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 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);
}
}
}