From 20b6babc376d7123a725b5e6f831df2869fa5a38 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 3 Jul 2025 15:55:48 -0300 Subject: [PATCH] =?UTF-8?q?feat(Worker):=20Implementa=20servicio=20de=20no?= =?UTF-8?q?tificaci=C3=B3n=20para=20alertas=20de=20fallos=20cr=C3=ADticos?= =?UTF-8?q?=20-=20Se=20remueve=20.env=20y=20se=20utilizan=20appsettings.De?= =?UTF-8?q?velopment.json=20y=20User=20Secrets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 16 +- src/Mercados.Api/Program.cs | 43 ++-- .../DataFetchers/BcrDataFetcher.cs | 10 +- .../DataFetchers/FinnhubDataFetcher.cs | 11 +- .../DataFetchers/MercadoAgroFetcher.cs | 9 +- .../DataFetchers/YahooFinanceDataFetcher.cs | 20 +- .../Mercados.Infrastructure.csproj | 1 + .../Persistence/SqlConnectionFactory.cs | 10 +- .../Services/EmailNotificationService.cs | 89 ++++++++ .../Services/INotificationService.cs | 15 ++ src/Mercados.Worker/DataFetchingService.cs | 202 ++++++++---------- src/Mercados.Worker/Program.cs | 78 +++---- 12 files changed, 292 insertions(+), 212 deletions(-) create mode 100644 src/Mercados.Infrastructure/Services/EmailNotificationService.cs create mode 100644 src/Mercados.Infrastructure/Services/INotificationService.cs diff --git a/.env b/.env index 4287c9c..dea7adf 100644 --- a/.env +++ b/.env @@ -1,7 +1,15 @@ # --- Conexión a la Base de Datos --- -DB_CONNECTION_STRING="Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;" +ConnectionStrings__DefaultConnection="Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;" # --- Claves de APIs Externas --- -FINNHUB_API_KEY="cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30" -BCR_API_KEY="D1782A51-A5FD-EF11-9445-00155D09E201" -BCR_API_SECRET="da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3" \ No newline at end of file +ApiKeys__Finnhub="cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30" +ApiKeys__Bcr__Key="D1782A51-A5FD-EF11-9445-00155D09E201" +ApiKeys__Bcr__Secret="da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3" + +# --- Configuración de Email para Alertas --- +SMTP_HOST="mail.eldia.com" +SMTP_PORT="587" +SMTP_USER="alertas@eldia.com" +SMTP_PASS="@Alertas713550@" +EMAIL_SENDER_NAME="Servicio de Mercados" +EMAIL_RECIPIENT="dmolinari@eldia.com" \ No newline at end of file diff --git a/src/Mercados.Api/Program.cs b/src/Mercados.Api/Program.cs index 37cea6a..6207ff6 100644 --- a/src/Mercados.Api/Program.cs +++ b/src/Mercados.Api/Program.cs @@ -1,12 +1,19 @@ +using DotNetEnv; using FluentMigrator.Runner; using Mercados.Database.Migrations; using Mercados.Infrastructure; using Mercados.Infrastructure.Persistence; using Mercados.Infrastructure.Persistence.Repositories; -using System.Reflection; +using DotNetEnv.Configuration; -// Carga las variables de entorno desde el archivo .env en la raíz de la solución. -DotNetEnv.Env.Load(); + +var envFilePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../.env")); + +// Cargamos el archivo .env desde la ruta explícita. +if (!Env.Load(envFilePath).Any()) +{ + Console.WriteLine($"ADVERTENCIA: No se pudo encontrar el archivo .env en la ruta: {envFilePath}"); +} var builder = WebApplication.CreateBuilder(args); @@ -19,55 +26,45 @@ builder.Services.AddCors(options => options.AddPolicy(name: MyAllowSpecificOrigins, policy => { - policy.WithOrigins("http://localhost:5173", // Desarrollo Frontend - "http://192.168.10.78:5173", // Desarrollo en Red Local - "https://www.eldia.com" // <--- DOMINIO DE PRODUCCIÓN - ) + policy.WithOrigins("http://localhost:5173", + "http://192.168.10.78:5173", + "https://www.eldia.com") .AllowAnyHeader() .AllowAnyMethod(); }); }); -// 1. Registramos nuestra fábrica de conexiones a la BD. +// Registros de servicios (esto está perfecto) builder.Services.AddSingleton(); - -// 2. AÑADIR: Registramos los repositorios que la API necesitará para LEER datos. builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -// 3. Configurar FluentMigrator +// Configuración de FluentMigrator (perfecto) builder.Services .AddFluentMigratorCore() .ConfigureRunner(rb => rb - // Usar el conector para SQL Server .AddSqlServer() - // Obtener la cadena de conexión desde appsettings.json .WithGlobalConnectionString(builder.Configuration.GetConnectionString("DefaultConnection")) - // Definir el ensamblado (proyecto) que contiene las migraciones .ScanIn(typeof(CreateInitialTables).Assembly).For.Migrations()) - // Habilitar el logging para ver qué hacen las migraciones en la consola .AddLogging(lb => lb.AddFluentMigratorConsole()); - -// Add services to the container. +// Servicios del contenedor estándar (perfecto) builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); -// 4. Ejecutar las migraciones al iniciar la aplicación (ideal para desarrollo y despliegues sencillos) -// Obtenemos el "scope" de los servicios para poder solicitar el MigrationRunner +// Ejecución de migraciones (perfecto) using (var scope = app.Services.CreateScope()) { var migrationRunner = scope.ServiceProvider.GetRequiredService(); - // Ejecuta las migraciones pendientes migrationRunner.MigrateUp(); } -// Configure the HTTP request pipeline. +// Pipeline de HTTP (perfecto) if (app.Environment.IsDevelopment()) { app.UseSwagger(); @@ -75,11 +72,7 @@ if (app.Environment.IsDevelopment()) } app.UseHttpsRedirection(); - app.UseCors(MyAllowSpecificOrigins); - app.UseAuthorization(); - app.MapControllers(); - app.Run(); \ No newline at end of file diff --git a/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs index 6100fd0..aad2d76 100644 --- a/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs +++ b/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs @@ -105,7 +105,11 @@ namespace Mercados.Infrastructure.DataFetchers } } - if (!cotizaciones.Any()) return (false, "No se obtuvieron datos de granos de BCR."); + if (!cotizaciones.Any()) + { + _logger.LogInformation("La conexión con {SourceName} fue exitosa, pero no se encontraron datos de granos.", SourceName); + return (true, "Conexión exitosa, pero no se encontraron nuevos datos de granos."); + } await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); await UpdateSourceInfoAsync(); @@ -123,8 +127,8 @@ namespace Mercados.Infrastructure.DataFetchers private async Task GetAuthTokenAsync(HttpClient client) { var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/Login"); - request.Headers.Add("api_key", Environment.GetEnvironmentVariable("BCR_API_KEY")); - request.Headers.Add("secret", Environment.GetEnvironmentVariable("BCR_API_SECRET")); + request.Headers.Add("api_key", _configuration["ApiKeys:Bcr:Key"]); + request.Headers.Add("secret", _configuration["ApiKeys:Bcr:Secret"]); var response = await client.SendAsync(request); response.EnsureSuccessStatusCode(); diff --git a/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs index 05dc889..b3c2b2b 100644 --- a/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs +++ b/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs @@ -31,12 +31,11 @@ namespace Mercados.Infrastructure.DataFetchers IFuenteDatoRepository fuenteDatoRepository, ILogger logger) { - var apiKey = Environment.GetEnvironmentVariable("FINNHUB_API_KEY"); + var apiKey = configuration["ApiKeys:Finnhub"]; if (string.IsNullOrEmpty(apiKey)) { - throw new InvalidOperationException("La clave de API de Finnhub no está configurada en appsettings.json (ApiKeys:Finnhub)"); + throw new InvalidOperationException("La clave de API de Finnhub no está configurada (ApiKeys:Finnhub)"); } - // Le pasamos el cliente HTTP que ya está configurado con Polly en Program.cs _client = new FinnhubClient(httpClientFactory.CreateClient("FinnhubDataFetcher"), apiKey); _cotizacionRepository = cotizacionRepository; _fuenteDatoRepository = fuenteDatoRepository; @@ -76,7 +75,11 @@ namespace Mercados.Infrastructure.DataFetchers } } - if (!cotizaciones.Any()) return (false, "No se obtuvieron datos de Finnhub."); + if (!cotizaciones.Any()) + { + _logger.LogInformation("La conexión con {SourceName} fue exitosa, pero no se obtuvieron cotizaciones de los tickers solicitados.", SourceName); + return (true, "Conexión exitosa, pero no se encontraron cotizaciones."); + } await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); await UpdateSourceInfoAsync(); diff --git a/src/Mercados.Infrastructure/DataFetchers/MercadoAgroFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/MercadoAgroFetcher.cs index 08af7b4..418e559 100644 --- a/src/Mercados.Infrastructure/DataFetchers/MercadoAgroFetcher.cs +++ b/src/Mercados.Infrastructure/DataFetchers/MercadoAgroFetcher.cs @@ -10,7 +10,6 @@ namespace Mercados.Infrastructure.DataFetchers { public string SourceName => "MercadoAgroganadero"; private const string DataUrl = "https://www.mercadoagroganadero.com.ar/dll/hacienda6.dll/haciinfo000225"; - private readonly IHttpClientFactory _httpClientFactory; private readonly ICotizacionGanadoRepository _cotizacionRepository; private readonly IFuenteDatoRepository _fuenteDatoRepository; @@ -37,13 +36,18 @@ namespace Mercados.Infrastructure.DataFetchers var htmlContent = await GetHtmlContentAsync(); if (string.IsNullOrEmpty(htmlContent)) { + // Esto sigue siendo un fallo, no se pudo obtener la página return (false, "No se pudo obtener el contenido HTML."); } var cotizaciones = ParseHtmlToEntities(htmlContent); + if (!cotizaciones.Any()) { - return (false, "No se encontraron cotizaciones válidas en el HTML."); + // La conexión fue exitosa, pero no se encontraron datos válidos. + // Esto NO es un error crítico, es un estado informativo. + _logger.LogInformation("La conexión con {SourceName} fue exitosa, pero no se encontraron datos de cotizaciones para procesar.", SourceName); + return (true, "Conexión exitosa, pero no se encontraron nuevos datos."); } await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); @@ -54,6 +58,7 @@ namespace Mercados.Infrastructure.DataFetchers } catch (Exception ex) { + // Un catch aquí sí es un error real (ej. 404, timeout, etc.) _logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName); return (false, $"Error: {ex.Message}"); } diff --git a/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs index 05bb29a..1401015 100644 --- a/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs +++ b/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs @@ -10,18 +10,18 @@ namespace Mercados.Infrastructure.DataFetchers public string SourceName => "YahooFinance"; private readonly List _tickers = new() { "^GSPC", // Índice S&P 500 - "^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.BA", - "TECO2.BA", "EDN.BA", "CRES.BA", "TXAR.BA", "MIRG.BA", + "^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.BA", + "TECO2.BA", "EDN.BA", "CRES.BA", "TXAR.BA", "MIRG.BA", "CEPU.BA", "LOMA.BA", "VALO.BA", "MELI.BA" }; - + private readonly ICotizacionBolsaRepository _cotizacionRepository; private readonly IFuenteDatoRepository _fuenteDatoRepository; private readonly ILogger _logger; public YahooFinanceDataFetcher( - ICotizacionBolsaRepository cotizacionRepository, - IFuenteDatoRepository fuenteDatoRepository, + ICotizacionBolsaRepository cotizacionRepository, + IFuenteDatoRepository fuenteDatoRepository, ILogger logger) { _cotizacionRepository = cotizacionRepository; @@ -40,7 +40,7 @@ namespace Mercados.Infrastructure.DataFetchers foreach (var sec in securities.Values) { if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue; - + string mercado = sec.Symbol.EndsWith(".BA") || sec.Symbol == "^MERV" ? "Local" : "EEUU"; cotizaciones.Add(new CotizacionBolsa @@ -56,7 +56,11 @@ namespace Mercados.Infrastructure.DataFetchers }); } - if (!cotizaciones.Any()) return (false, "No se obtuvieron datos de Yahoo Finance."); + if (!cotizaciones.Any()) + { + _logger.LogInformation("La conexión con {SourceName} fue exitosa, pero no se obtuvieron cotizaciones de los tickers solicitados.", SourceName); + return (true, "Conexión exitosa, pero no se encontraron cotizaciones."); + } await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); await UpdateSourceInfoAsync(); @@ -70,7 +74,7 @@ namespace Mercados.Infrastructure.DataFetchers return (false, $"Error: {ex.Message}"); } } - + private async Task UpdateSourceInfoAsync() { var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); diff --git a/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj b/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj index 07dfa60..d44f12e 100644 --- a/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj +++ b/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Mercados.Infrastructure/Persistence/SqlConnectionFactory.cs b/src/Mercados.Infrastructure/Persistence/SqlConnectionFactory.cs index 8343c4e..334c3c4 100644 --- a/src/Mercados.Infrastructure/Persistence/SqlConnectionFactory.cs +++ b/src/Mercados.Infrastructure/Persistence/SqlConnectionFactory.cs @@ -1,6 +1,6 @@ using Mercados.Infrastructure.Persistence; using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration; // Asegúrate de que este using esté using System.Data; namespace Mercados.Infrastructure @@ -11,14 +11,14 @@ namespace Mercados.Infrastructure public SqlConnectionFactory(IConfiguration configuration) { - // Leemos directamente de la variable de entorno - _connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING") - ?? throw new ArgumentNullException(nameof(configuration), "La variable de entorno 'DB_CONNECTION_STRING' no fue encontrada."); + // Variable de entorno 'DB_CONNECTION_STRING' si está disponible, + // o el valor de appsettings.json si no lo está. + _connectionString = configuration.GetConnectionString("DefaultConnection") + ?? throw new ArgumentNullException(nameof(configuration), "La cadena de conexión 'DefaultConnection' no fue encontrada."); } public IDbConnection CreateConnection() { - // Dapper se encargará de abrir y cerrar la conexión automáticamente. return new SqlConnection(_connectionString); } } diff --git a/src/Mercados.Infrastructure/Services/EmailNotificationService.cs b/src/Mercados.Infrastructure/Services/EmailNotificationService.cs new file mode 100644 index 0000000..1ef141c --- /dev/null +++ b/src/Mercados.Infrastructure/Services/EmailNotificationService.cs @@ -0,0 +1,89 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using MimeKit; + +namespace Mercados.Infrastructure.Services +{ + public class EmailNotificationService : INotificationService + { + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public EmailNotificationService(ILogger logger, IConfiguration configuration) + { + _logger = logger; + _configuration = configuration; + } + + public async Task SendFailureAlertAsync(string subject, string message, DateTime? eventTimeUtc = null) + { + // Leemos la configuración de forma segura desde IConfiguration (que a su vez lee el .env) + var smtpHost = _configuration["SMTP_HOST"]; + var smtpPort = int.Parse(_configuration["SMTP_PORT"] ?? "587"); + var smtpUser = _configuration["SMTP_USER"]; + var smtpPass = _configuration["SMTP_PASS"]; + var senderName = _configuration["EMAIL_SENDER_NAME"]; + var recipient = _configuration["EMAIL_RECIPIENT"]; + + if (string.IsNullOrEmpty(smtpHost) || string.IsNullOrEmpty(smtpUser) || string.IsNullOrEmpty(smtpPass)) + { + _logger.LogError("La configuración SMTP está incompleta. No se puede enviar el email de alerta."); + return; + } + + // Usamos la hora actual en UTC para el evento. + var displayTime = DateTime.UtcNow; + + // Buscamos la zona horaria de Argentina + TimeZoneInfo argentinaTimeZone; + try + { + argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"); + } + catch (TimeZoneNotFoundException) + { + argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time"); + } + + // Convertimos la hora UTC a la hora local de Argentina + var localTime = TimeZoneInfo.ConvertTimeFromUtc(displayTime, argentinaTimeZone); + + try + { + var email = new MimeMessage(); + email.From.Add(new MailboxAddress(senderName, smtpUser)); + email.To.Add(MailboxAddress.Parse(recipient)); + email.Subject = subject; + + // Creamos un cuerpo de correo un poco más elaborado + var builder = new BodyBuilder + { + HtmlBody = $@" +

Alerta del Servicio de Mercados

+

Se ha detectado un error crítico que requiere atención.

+
+

Detalles del Error:

+

Mensaje: {message}

+

Hora del Evento (AR): {localTime:yyyy-MM-dd HH:mm:ss}

" + }; + email.Body = builder.ToMessageBody(); + + using var smtp = new SmtpClient(); + // Usamos SecureSocketOptions.StartTls que es el estándar moderno para el puerto 587. + // Si tu servidor usa el puerto 465, deberías usar SecureSocketOptions.SslOnConnect. + await smtp.ConnectAsync(smtpHost, smtpPort, SecureSocketOptions.StartTls); + await smtp.AuthenticateAsync(smtpUser, smtpPass); + await smtp.SendAsync(email); + await smtp.DisconnectAsync(true); + + _logger.LogInformation("Email de alerta enviado exitosamente a {Recipient}", recipient); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "FALLO EL ENVÍO DEL EMAIL DE ALERTA. Revisa la configuración SMTP y la conectividad."); + } + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Services/INotificationService.cs b/src/Mercados.Infrastructure/Services/INotificationService.cs new file mode 100644 index 0000000..a7062f1 --- /dev/null +++ b/src/Mercados.Infrastructure/Services/INotificationService.cs @@ -0,0 +1,15 @@ +namespace Mercados.Infrastructure.Services +{ + /// + /// Define un servicio para enviar notificaciones y alertas. + /// + public interface INotificationService + { + /// + /// Envía una alerta de fallo crítico. + /// + /// El título de la alerta. + /// El mensaje detallado del error. + Task SendFailureAlertAsync(string subject, string message, DateTime? eventTimeUtc = null); + } +} \ No newline at end of file diff --git a/src/Mercados.Worker/DataFetchingService.cs b/src/Mercados.Worker/DataFetchingService.cs index f8ea02d..8ef437d 100644 --- a/src/Mercados.Worker/DataFetchingService.cs +++ b/src/Mercados.Worker/DataFetchingService.cs @@ -1,5 +1,7 @@ -using Mercados.Infrastructure.DataFetchers; using Cronos; +using Mercados.Infrastructure.DataFetchers; +using Mercados.Infrastructure.Services; +using Microsoft.Extensions.Configuration; namespace Mercados.Worker { @@ -12,31 +14,47 @@ namespace Mercados.Worker private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; private readonly TimeZoneInfo _argentinaTimeZone; - private readonly IConfiguration _configuration; - // Diccionario para rastrear la última vez que se ejecutó una tarea diaria - // y evitar que se ejecute múltiples veces si el servicio se reinicia. - private readonly Dictionary _lastDailyRun = new(); + // Almacenamos las expresiones Cron parseadas para no tener que hacerlo en cada ciclo. + private readonly CronExpression _agroSchedule; + private readonly CronExpression _bcrSchedule; + private readonly CronExpression _bolsasSchedule; - public DataFetchingService(ILogger logger, IServiceProvider serviceProvider,IConfiguration configuration) + // Almacenamos la próxima ejecución calculada para cada tarea. + private DateTime? _nextAgroRun; + private DateTime? _nextBcrRun; + private DateTime? _nextBolsasRun; + + // Diccionario para rastrear la hora de la última alerta ENVIADA por cada tarea. + private readonly Dictionary _lastAlertSent = new(); + // Definimos el período de "silencio" para las alertas (ej. 4 horas). + private readonly TimeSpan _alertSilencePeriod = TimeSpan.FromHours(4); + + public DataFetchingService( + ILogger logger, + IServiceProvider serviceProvider, + IConfiguration configuration) { _logger = logger; _serviceProvider = serviceProvider; - _configuration = configuration; - + // Se define explícitamente la zona horaria de Argentina. - // Esto asegura que los cálculos de tiempo sean correctos, sin importar - // la configuración de zona horaria del servidor donde se ejecute el worker. try { - // El ID estándar para Linux y macOS _argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"); } catch (TimeZoneNotFoundException) { - // El ID equivalente para Windows _argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time"); } + + // Parseamos las expresiones Cron UNA SOLA VEZ, en el constructor. + // Si una expresión es inválida o nula, el servicio fallará al iniciar, + // lo cual es un comportamiento deseable para alertar de una mala configuración. + // El '!' le dice al compilador que confiamos que estos valores no serán nulos. + _agroSchedule = CronExpression.Parse(configuration["Schedules:MercadoAgroganadero"]!); + _bcrSchedule = CronExpression.Parse(configuration["Schedules:BCR"]!); + _bolsasSchedule = CronExpression.Parse(configuration["Schedules:Bolsas"]!); } /// @@ -46,106 +64,58 @@ namespace Mercados.Worker { _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); - // Se recomienda una ejecución inicial para poblar la base de datos inmediatamente - // al iniciar el servicio, en lugar de esperar al primer horario programado. - //await RunAllFetchersAsync(stoppingToken); + // Ejecutamos una vez al inicio para tener datos frescos inmediatamente. + await RunAllFetchersAsync(stoppingToken); - // PeriodicTimer es una forma moderna y eficiente de crear un bucle de "tic-tac" - // sin bloquear un hilo con Task.Delay. - using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); + // Calculamos las primeras ejecuciones programadas al arrancar. + var utcNow = DateTime.UtcNow; + _nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); + _nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); + _nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); + + // Usamos un PeriodicTimer que "despierta" cada 30 segundos para revisar si hay tareas pendientes. + // Un intervalo más corto aumenta la precisión del disparo de las tareas. + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); - // El bucle se ejecuta cada minuto mientras el servicio no reciba una señal de detención. while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) { - await RunScheduledTasksAsync(stoppingToken); - } - } + utcNow = DateTime.UtcNow; - /// - /// Revisa la hora actual y ejecuta las tareas que coincidan con su horario programado. - /// - private async Task RunScheduledTasksAsync(CancellationToken stoppingToken) - { - var utcNow = DateTime.UtcNow; - - // Tareas diarias (estas suelen ser rápidas y no se solapan, no es crítico paralelizar) - // Mantenerlas secuenciales puede ser más simple de leer. - string? agroSchedule = _configuration["Schedules:MercadoAgroganadero"]; - if (!string.IsNullOrEmpty(agroSchedule)) - { - await TryRunDailyTaskAsync("MercadoAgroganadero", agroSchedule, utcNow, stoppingToken); - } - else { _logger.LogWarning("..."); } - - string? bcrSchedule = _configuration["Schedules:BCR"]; - if (!string.IsNullOrEmpty(bcrSchedule)) - { - await TryRunDailyTaskAsync("BCR", bcrSchedule, utcNow, stoppingToken); - } - else { _logger.LogWarning("..."); } - - // --- Tareas Recurrentes (Bolsas) --- - string? bolsasSchedule = _configuration["Schedules:Bolsas"]; - if (!string.IsNullOrEmpty(bolsasSchedule)) - { - // Reemplazamos la llamada secuencial con la ejecución paralela - await TryRunRecurringTaskInParallelAsync(new[] { "YahooFinance", "Finnhub" }, bolsasSchedule, utcNow, stoppingToken); - } - else { _logger.LogWarning("..."); } - } - - /// - /// Comprueba y ejecuta una tarea que debe correr solo una vez al día. - /// - private async Task TryRunDailyTaskAsync(string taskName, string cronExpression, DateTime utcNow, CancellationToken stoppingToken) - { - var cron = CronExpression.Parse(cronExpression); - var nextOccurrence = cron.GetNextOccurrence(utcNow.AddMinutes(-1)); - - if (nextOccurrence.HasValue && nextOccurrence.Value <= utcNow) - { - if (HasNotRunToday(taskName)) + // Comprobamos si ha llegado el momento de la próxima ejecución para cada tarea. + if (_nextAgroRun.HasValue && utcNow >= _nextAgroRun.Value) { - await RunFetcherByNameAsync(taskName, stoppingToken); - _lastDailyRun[taskName] = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _argentinaTimeZone).Date; + await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken); + // Inmediatamente después de ejecutar, calculamos la SIGUIENTE ocurrencia. + _nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); + } + + if (_nextBcrRun.HasValue && utcNow >= _nextBcrRun.Value) + { + await RunFetcherByNameAsync("BCR", stoppingToken); + _nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); + } + + if (_nextBolsasRun.HasValue && utcNow >= _nextBolsasRun.Value) + { + _logger.LogInformation("Ventana de ejecución para Bolsas. Iniciando en paralelo..."); + await Task.WhenAll( + RunFetcherByNameAsync("YahooFinance", stoppingToken), + RunFetcherByNameAsync("Finnhub", stoppingToken) + ); + _nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); } } } /// - /// Comprueba y ejecuta una tarea que puede correr múltiples veces al día. - /// - private async Task TryRunRecurringTaskInParallelAsync(string[] taskNames, string cronExpression, DateTime utcNow, CancellationToken stoppingToken) - { - var cron = CronExpression.Parse(cronExpression, CronFormat.IncludeSeconds); - var nextOccurrence = cron.GetNextOccurrence(utcNow.AddMinutes(-1)); - - if (nextOccurrence.HasValue && nextOccurrence.Value <= utcNow) - { - _logger.LogInformation("Ventana de ejecución para: {Tasks}. Iniciando en paralelo...", string.Join(", ", taskNames)); - - // Creamos una lista de tareas, una por cada fetcher a ejecutar - var tasks = taskNames.Select(taskName => RunFetcherByNameAsync(taskName, stoppingToken)).ToList(); - - // Iniciamos todas las tareas a la vez y esperamos a que todas terminen - await Task.WhenAll(tasks); - - _logger.LogInformation("Todas las tareas recurrentes han finalizado."); - } - } - - /// - /// Ejecuta un fetcher específico por su nombre. Utiliza un scope de DI para gestionar - /// correctamente el ciclo de vida de los servicios (como las conexiones a la BD). + /// Ejecuta un fetcher específico por su nombre, gestionando el scope de DI y las notificaciones. /// private async Task RunFetcherByNameAsync(string sourceName, CancellationToken stoppingToken) { if (stoppingToken.IsCancellationRequested) return; _logger.LogInformation("Intentando ejecutar fetcher: {sourceName}", sourceName); - - // Crea un "scope" de servicios. Todos los servicios "scoped" (como los repositorios) - // se crearán de nuevo para esta ejecución y se desecharán al final, evitando problemas. + using var scope = _serviceProvider.CreateScope(); var fetchers = scope.ServiceProvider.GetRequiredService>(); var fetcher = fetchers.FirstOrDefault(f => f.SourceName.Equals(sourceName, StringComparison.OrdinalIgnoreCase)); @@ -155,7 +125,19 @@ namespace Mercados.Worker var (success, message) = await fetcher.FetchDataAsync(); if (!success) { - _logger.LogError("Falló la ejecución del fetcher {sourceName}: {message}", sourceName, message); + var errorMessage = $"Falló la ejecución del fetcher {sourceName}: {message}"; + _logger.LogError(errorMessage); + + if (ShouldSendAlert(sourceName)) + { + var notifier = scope.ServiceProvider.GetRequiredService(); + await notifier.SendFailureAlertAsync($"Fallo Crítico en el Fetcher: {sourceName}", errorMessage, DateTime.UtcNow); + _lastAlertSent[sourceName] = DateTime.UtcNow; + } + else + { + _logger.LogWarning("Fallo repetido para {sourceName}. Alerta silenciada temporalmente.", sourceName); + } } } else @@ -165,31 +147,35 @@ namespace Mercados.Worker } /// - /// Ejecuta todos los fetchers al iniciar el servicio. Esto es útil para poblar - /// la base de datos inmediatamente al arrancar el worker. + /// Ejecuta todos los fetchers en paralelo al iniciar el servicio. /// - /* private async Task RunAllFetchersAsync(CancellationToken stoppingToken) { _logger.LogInformation("Ejecutando todos los fetchers al iniciar en paralelo..."); using var scope = _serviceProvider.CreateScope(); var fetchers = scope.ServiceProvider.GetRequiredService>(); - - // Creamos una lista de tareas, una por cada fetcher disponible - var tasks = fetchers.Select(fetcher => RunFetcherByNameAsync(fetcher.SourceName, stoppingToken)).ToList(); - - // Ejecutamos todo y esperamos + + var tasks = fetchers.Select(fetcher => RunFetcherByNameAsync(fetcher.SourceName, stoppingToken)); + await Task.WhenAll(tasks); _logger.LogInformation("Ejecución inicial de todos los fetchers completada."); } - */ - + #region Funciones de Ayuda para la Planificación - private bool HasNotRunToday(string taskName) + /// + /// Determina si se debe enviar una alerta o si está en período de silencio. + /// + private bool ShouldSendAlert(string taskName) { - return !_lastDailyRun.ContainsKey(taskName) || _lastDailyRun[taskName].Date < TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _argentinaTimeZone).Date; + if (!_lastAlertSent.ContainsKey(taskName)) + { + return true; + } + + var lastAlertTime = _lastAlertSent[taskName]; + return DateTime.UtcNow.Subtract(lastAlertTime) > _alertSilencePeriod; } #endregion diff --git a/src/Mercados.Worker/Program.cs b/src/Mercados.Worker/Program.cs index 36b7d16..2c7aabf 100644 --- a/src/Mercados.Worker/Program.cs +++ b/src/Mercados.Worker/Program.cs @@ -6,90 +6,62 @@ using Mercados.Infrastructure.Persistence.Repositories; using Mercados.Worker; using Polly; using Polly.Extensions.Http; +using Mercados.Infrastructure.Services; +using DotNetEnv; +using DotNetEnv.Configuration; -// Carga las variables de entorno desde el archivo .env en la raíz de la solución. -DotNetEnv.Env.Load(); +var envFilePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../.env")); + +// Cargamos el archivo .env desde la ruta explícita. +// Si no lo encuentra, Load retornará false. +if (!Env.Load(envFilePath).Any()) +{ + Console.WriteLine($"ADVERTENCIA: No se pudo encontrar el archivo .env en la ruta: {envFilePath}"); +} Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); -// --- Configuración del Host --- -// Esto prepara el host del servicio, permitiendo la inyección de dependencias, -// la configuración desde appsettings.json y el logging. + IHost host = Host.CreateDefaultBuilder(args) .ConfigureServices((hostContext, services) => { - // Obtenemos la configuración desde el host builder para usarla aquí. + // La línea 'config.AddDotNetEnv(optional: true);' ha sido eliminada. + IConfiguration configuration = hostContext.Configuration; // --- 1. Registro de Servicios de Infraestructura --- - - // Registramos la fábrica de conexiones a la BD. Es un Singleton porque - // solo necesita ser creada una vez para leer la cadena de conexión. services.AddSingleton(); - - // Registramos los repositorios. Se crean "por petición" (Scoped). - // En un worker, "Scoped" significa que se creará una instancia por cada - // ejecución del servicio, lo cual es seguro y eficiente. services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + //services.AddScoped(); + services.AddScoped(); // --- 2. Registro de los Data Fetchers --- - - // Registramos CADA uno de nuestros fetchers. El contenedor de DI sabrá - // que todos implementan la interfaz IDataFetcher. + // Descomentados para la versión final y funcional. services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); - // El cliente HTTP es fundamental para hacer llamadas a APIs externas. - // Le damos un nombre al cliente de Finnhub para cumplir con los requisitos de su constructor. - //services.AddHttpClient("Finnhub"); - - // Configuramos CADA cliente HTTP que nuestros fetchers usan. - // IHttpClientFactory nos permite nombrar y configurar clientes de forma independiente. - - // Cliente para el scraper del MercadoAgro, con una política de reintentos - services.AddHttpClient("MercadoAgroFetcher") - .AddPolicyHandler(GetRetryPolicy()); + // --- 3. Configuración de Clientes HTTP con Polly --- + services.AddHttpClient("MercadoAgroFetcher").AddPolicyHandler(GetRetryPolicy()); + services.AddHttpClient("BcrDataFetcher").AddPolicyHandler(GetRetryPolicy()); + services.AddHttpClient("FinnhubDataFetcher").AddPolicyHandler(GetRetryPolicy()); - // Cliente para la API de BCR, con la misma política de reintentos - services.AddHttpClient("BcrDataFetcher") - .AddPolicyHandler(GetRetryPolicy()); - - // Cliente para Finnhub, con la misma política de reintentos - services.AddHttpClient("FinnhubDataFetcher") - .AddPolicyHandler(GetRetryPolicy()); - - // Cliente para YahooFinance (aunque es menos probable que falle, es buena práctica incluirlo) - // La librería YahooFinanceApi usa su propio HttpClient, así que esta configuración - // no le afectará directamente. La resiliencia para YahooFinance la manejaremos de otra forma si es necesario. - // Por ahora, lo dejamos así y nos enfocamos en los que usan IHttpClientFactory. - - - // --- 3. Registro del Worker Principal --- - - // Finalmente, registramos nuestro servicio de fondo (el worker en sí). + // --- 4. Registro del Worker Principal --- services.AddHostedService(); }) .Build(); -// Esta función define nuestra política de reintentos. static IAsyncPolicy GetRetryPolicy() { - // Polly.Extensions.Http nos da este método conveniente. return HttpPolicyExtensions - // Maneja errores de red transitorios O códigos de estado de servidor que indican un problema temporal. - .HandleTransientHttpError() - // También maneja el error 408 Request Timeout - .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.RequestTimeout) - // Política de reintento con espera exponencial: 3 reintentos, esperando 2^intento segundos. - .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + .HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.RequestTimeout) + .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), onRetry: (outcome, timespan, retryAttempt, context) => { - // Registramos un log cada vez que se realiza un reintento. - // Esta es una forma de hacerlo sin tener acceso directo al ILogger aquí. Console.WriteLine($"[Polly] Reintentando petición... Intento {retryAttempt}. Esperando {timespan.TotalSeconds}s. Causa: {outcome.Exception?.Message ?? outcome.Result.ReasonPhrase}"); }); }