Compare commits
	
		
			2 Commits
		
	
	
		
			4cc9d239cf
			...
			5e317ab304
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 5e317ab304 | |||
| 20b6babc37 | 
							
								
								
									
										7
									
								
								.env
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								.env
									
									
									
									
									
								
							| @@ -1,7 +0,0 @@ | ||||
| # --- Conexión a la Base de Datos --- | ||||
| DB_CONNECTION_STRING="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" | ||||
| @@ -1,13 +1,13 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
|  | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net9.0</TargetFramework> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <UserSecretsId>28c6a673-1f1e-4140-aa75-a0d894d1fbc4</UserSecretsId> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="DotNetEnv" Version="3.1.1" /> | ||||
|     <PackageReference Include="FluentMigrator.Runner" Version="7.1.0" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" /> | ||||
|     <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" /> | ||||
|   | ||||
| @@ -3,10 +3,7 @@ using Mercados.Database.Migrations; | ||||
| using Mercados.Infrastructure; | ||||
| using Mercados.Infrastructure.Persistence; | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using System.Reflection; | ||||
|  | ||||
| // Carga las variables de entorno desde el archivo .env en la raíz de la solución. | ||||
| DotNetEnv.Env.Load(); | ||||
| using Mercados.Api.Utils; | ||||
|  | ||||
| var builder = WebApplication.CreateBuilder(args); | ||||
|  | ||||
| @@ -19,55 +16,52 @@ 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<IDbConnectionFactory, SqlConnectionFactory>(); | ||||
|  | ||||
| // 2. AÑADIR: Registramos los repositorios que la API necesitará para LEER datos. | ||||
| builder.Services.AddScoped<ICotizacionGanadoRepository, CotizacionGanadoRepository>(); | ||||
| builder.Services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); | ||||
| builder.Services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); | ||||
| builder.Services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>(); | ||||
|  | ||||
| // 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. | ||||
| builder.Services.AddControllers() | ||||
|     .AddJsonOptions(options => | ||||
|     { | ||||
|         // Añadimos nuestro convertidor personalizado para manejar las fechas. | ||||
|         options.JsonSerializerOptions.Converters.Add(new UtcDateTimeConverter()); | ||||
|     }); | ||||
|  | ||||
| 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<IMigrationRunner>(); | ||||
|     // Ejecuta las migraciones pendientes | ||||
|     migrationRunner.MigrateUp(); | ||||
| } | ||||
|  | ||||
| // Configure the HTTP request pipeline. | ||||
| // Pipeline de HTTP (perfecto) | ||||
| if (app.Environment.IsDevelopment()) | ||||
| { | ||||
|     app.UseSwagger(); | ||||
| @@ -75,11 +69,7 @@ if (app.Environment.IsDevelopment()) | ||||
| } | ||||
|  | ||||
| app.UseHttpsRedirection(); | ||||
|  | ||||
| app.UseCors(MyAllowSpecificOrigins); | ||||
|  | ||||
| app.UseAuthorization(); | ||||
|  | ||||
| app.MapControllers(); | ||||
|  | ||||
| app.Run(); | ||||
							
								
								
									
										27
									
								
								src/Mercados.Api/Utils/UtcDateTimeConverter.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/Mercados.Api/Utils/UtcDateTimeConverter.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace Mercados.Api.Utils | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Un convertidor de JSON personalizado para asegurar que los objetos DateTime | ||||
|     /// se serialicen al formato ISO 8601 en UTC (con el designador 'Z'). | ||||
|     /// </summary> | ||||
|     public class UtcDateTimeConverter : JsonConverter<DateTime> | ||||
|     { | ||||
|         public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) | ||||
|         { | ||||
|             // Al leer un string de fecha, nos aseguramos de que se interprete como UTC | ||||
|             return reader.GetDateTime().ToUniversalTime(); | ||||
|         } | ||||
|  | ||||
|         public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) | ||||
|         { | ||||
|             // Antes de escribir el string, especificamos que el 'Kind' es Utc. | ||||
|             // Si ya es Utc, no hace nada. Si es Local o Unspecified, lo trata como si fuera Utc. | ||||
|             // Esto es seguro porque sabemos que todas nuestras fechas en la BD son UTC. | ||||
|             var utcValue = DateTime.SpecifyKind(value, DateTimeKind.Utc); | ||||
|             writer.WriteStringValue(utcValue); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -9,11 +9,24 @@ | ||||
|   "ConnectionStrings": { | ||||
|     "DefaultConnection": "" | ||||
|   }, | ||||
|   "Schedules": { | ||||
|     "MercadoAgroganadero": "0 11 * * 1-5", | ||||
|     "BCR": "30 11 * * 1-5", | ||||
|     "Bolsas": "10 11-17 * * 1-5" | ||||
|   }, | ||||
|   "ApiKeys": { | ||||
|     "Finnhub": "", | ||||
|     "Bcr": { | ||||
|       "Key": "", | ||||
|       "Secret": "" | ||||
|     } | ||||
|   }, | ||||
|   "SmtpSettings": { | ||||
|     "Host": "", | ||||
|     "Port": 587, | ||||
|     "User": "", | ||||
|     "Pass": "", | ||||
|     "SenderName": "Servicio de Mercados", | ||||
|     "Recipient": "" | ||||
|   } | ||||
| } | ||||
| @@ -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<string?> 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(); | ||||
|   | ||||
| @@ -31,12 +31,11 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|             IFuenteDatoRepository fuenteDatoRepository, | ||||
|             ILogger<FinnhubDataFetcher> 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(); | ||||
|   | ||||
| @@ -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}"); | ||||
|             } | ||||
|   | ||||
| @@ -10,18 +10,18 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|         public string SourceName => "YahooFinance"; | ||||
|         private readonly List<string> _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<YahooFinanceDataFetcher> _logger; | ||||
|  | ||||
|         public YahooFinanceDataFetcher( | ||||
|             ICotizacionBolsaRepository cotizacionRepository,  | ||||
|             IFuenteDatoRepository fuenteDatoRepository,  | ||||
|             ICotizacionBolsaRepository cotizacionRepository, | ||||
|             IFuenteDatoRepository fuenteDatoRepository, | ||||
|             ILogger<YahooFinanceDataFetcher> 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); | ||||
|   | ||||
| @@ -7,6 +7,7 @@ | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="AngleSharp" Version="1.3.0" /> | ||||
|     <PackageReference Include="Dapper" Version="2.1.66" /> | ||||
|     <PackageReference Include="MailKit" Version="4.13.0" /> | ||||
|     <PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" /> | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -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<EmailNotificationService> _logger; | ||||
|         private readonly IConfiguration _configuration; | ||||
|  | ||||
|         public EmailNotificationService(ILogger<EmailNotificationService> 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["SmtpSettings:Host"]; | ||||
|             var smtpPort = _configuration.GetValue<int>("SmtpSettings:Port"); | ||||
|             var smtpUser = _configuration["SmtpSettings:User"]; | ||||
|             var smtpPass = _configuration["SmtpSettings:Pass"]; | ||||
|             var senderName = _configuration["SmtpSettings:SenderName"]; | ||||
|             var recipient = _configuration["SmtpSettings: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 = $@" | ||||
|                         <h1>Alerta del Servicio de Mercados</h1> | ||||
|                         <p>Se ha detectado un error crítico que requiere atención.</p> | ||||
|                         <hr> | ||||
|                         <h3>Detalles del Error:</h3> | ||||
|                         <p><strong>Mensaje:</strong> {message}</p> | ||||
|                         <p><strong>Hora del Evento (AR):</strong> {localTime:yyyy-MM-dd HH:mm:ss}</p>" | ||||
|                 }; | ||||
|                 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."); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										15
									
								
								src/Mercados.Infrastructure/Services/INotificationService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/Mercados.Infrastructure/Services/INotificationService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| namespace Mercados.Infrastructure.Services | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Define un servicio para enviar notificaciones y alertas. | ||||
|     /// </summary> | ||||
|     public interface INotificationService | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Envía una alerta de fallo crítico. | ||||
|         /// </summary> | ||||
|         /// <param name="subject">El título de la alerta.</param> | ||||
|         /// <param name="message">El mensaje detallado del error.</param> | ||||
|         Task SendFailureAlertAsync(string subject, string message, DateTime? eventTimeUtc = null); | ||||
|     } | ||||
| } | ||||
| @@ -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<DataFetchingService> _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<string, DateTime> _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<DataFetchingService> 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<string, DateTime> _lastAlertSent = new(); | ||||
|         // Definimos el período de "silencio" para las alertas (ej. 4 horas). | ||||
|         private readonly TimeSpan _alertSilencePeriod = TimeSpan.FromHours(4); | ||||
|  | ||||
|         public DataFetchingService( | ||||
|             ILogger<DataFetchingService> 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"]!); | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
| @@ -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; | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Revisa la hora actual y ejecuta las tareas que coincidan con su horario programado. | ||||
|         /// </summary> | ||||
|         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("..."); } | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Comprueba y ejecuta una tarea que debe correr solo una vez al día. | ||||
|         /// </summary> | ||||
|         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); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Comprueba y ejecuta una tarea que puede correr múltiples veces al día. | ||||
|         /// </summary> | ||||
|         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."); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         /// <summary> | ||||
|         /// 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. | ||||
|         /// </summary> | ||||
|         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<IEnumerable<IDataFetcher>>(); | ||||
|             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<INotificationService>(); | ||||
|                         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 | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// 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. | ||||
|         /// </summary> | ||||
|         /* | ||||
|         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<IEnumerable<IDataFetcher>>(); | ||||
|              | ||||
|             // 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) | ||||
|         /// <summary> | ||||
|         /// Determina si se debe enviar una alerta o si está en período de silencio. | ||||
|         /// </summary> | ||||
|         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 | ||||
|   | ||||
| @@ -9,7 +9,6 @@ | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Cronos" Version="0.11.0" /> | ||||
|     <PackageReference Include="DotNetEnv" Version="3.1.1" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.5" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   | ||||
| @@ -6,90 +6,43 @@ using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using Mercados.Worker; | ||||
| using Polly; | ||||
| using Polly.Extensions.Http; | ||||
| using Mercados.Infrastructure.Services; | ||||
|  | ||||
| // Carga las variables de entorno desde el archivo .env en la raíz de la solución. | ||||
| DotNetEnv.Env.Load(); | ||||
|  | ||||
| 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í. | ||||
|         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. | ||||
|         // El resto del código no cambia. IConfiguration recogerá automáticamente | ||||
|         // las variables de entorno que cargamos correctamente. | ||||
|         services.AddSingleton<IDbConnectionFactory, SqlConnectionFactory>(); | ||||
|  | ||||
|         // 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<ICotizacionGanadoRepository, CotizacionGanadoRepository>(); | ||||
|         services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); | ||||
|         services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); | ||||
|         services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>(); | ||||
|         services.AddScoped<INotificationService, EmailNotificationService>(); | ||||
|  | ||||
|         // --- 2. Registro de los Data Fetchers --- | ||||
|  | ||||
|         // Registramos CADA uno de nuestros fetchers. El contenedor de DI sabrá | ||||
|         // que todos implementan la interfaz IDataFetcher. | ||||
|         services.AddScoped<IDataFetcher, MercadoAgroFetcher>(); | ||||
|         services.AddScoped<IDataFetcher, BcrDataFetcher>(); | ||||
|         services.AddScoped<IDataFetcher, FinnhubDataFetcher>(); | ||||
|         services.AddScoped<IDataFetcher, YahooFinanceDataFetcher>(); | ||||
|  | ||||
|         // 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()); | ||||
|         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í). | ||||
|         services.AddHostedService<DataFetchingService>(); | ||||
|     }) | ||||
|     .Build(); | ||||
|  | ||||
| // Esta función define nuestra política de reintentos. | ||||
| static IAsyncPolicy<HttpResponseMessage> 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}"); | ||||
|             }); | ||||
| } | ||||
|   | ||||
| @@ -20,5 +20,13 @@ | ||||
|       "Key": "", | ||||
|       "Secret": "" | ||||
|     } | ||||
|   }, | ||||
|   "SmtpSettings": { | ||||
|     "Host": "", | ||||
|     "Port": 587, | ||||
|     "User": "", | ||||
|     "Pass": "", | ||||
|     "SenderName": "Servicio de Mercados", | ||||
|     "Recipient": "" | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user