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> |   <PropertyGroup> | ||||||
|     <TargetFramework>net9.0</TargetFramework> |     <TargetFramework>net9.0</TargetFramework> | ||||||
|     <Nullable>enable</Nullable> |     <Nullable>enable</Nullable> | ||||||
|     <ImplicitUsings>enable</ImplicitUsings> |     <ImplicitUsings>enable</ImplicitUsings> | ||||||
|  |     <UserSecretsId>28c6a673-1f1e-4140-aa75-a0d894d1fbc4</UserSecretsId> | ||||||
|   </PropertyGroup> |   </PropertyGroup> | ||||||
|  |  | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <PackageReference Include="DotNetEnv" Version="3.1.1" /> |  | ||||||
|     <PackageReference Include="FluentMigrator.Runner" Version="7.1.0" /> |     <PackageReference Include="FluentMigrator.Runner" Version="7.1.0" /> | ||||||
|     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" /> |     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" /> | ||||||
|     <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" /> |     <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" /> | ||||||
|   | |||||||
| @@ -3,10 +3,7 @@ using Mercados.Database.Migrations; | |||||||
| using Mercados.Infrastructure; | using Mercados.Infrastructure; | ||||||
| using Mercados.Infrastructure.Persistence; | using Mercados.Infrastructure.Persistence; | ||||||
| using Mercados.Infrastructure.Persistence.Repositories; | using Mercados.Infrastructure.Persistence.Repositories; | ||||||
| using System.Reflection; | using Mercados.Api.Utils; | ||||||
|  |  | ||||||
| // Carga las variables de entorno desde el archivo .env en la raíz de la solución. |  | ||||||
| DotNetEnv.Env.Load(); |  | ||||||
|  |  | ||||||
| var builder = WebApplication.CreateBuilder(args); | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
| @@ -19,55 +16,52 @@ builder.Services.AddCors(options => | |||||||
|     options.AddPolicy(name: MyAllowSpecificOrigins, |     options.AddPolicy(name: MyAllowSpecificOrigins, | ||||||
|                       policy => |                       policy => | ||||||
|                       { |                       { | ||||||
|                           policy.WithOrigins("http://localhost:5173", // Desarrollo Frontend |                           policy.WithOrigins("http://localhost:5173", | ||||||
|                                 "http://192.168.10.78:5173", // Desarrollo en Red Local |                                              "http://192.168.10.78:5173", | ||||||
|                                 "https://www.eldia.com" // <--- DOMINIO DE PRODUCCIÓN |                                              "https://www.eldia.com") | ||||||
|                                 ) |  | ||||||
|                                 .AllowAnyHeader() |                                 .AllowAnyHeader() | ||||||
|                                 .AllowAnyMethod(); |                                 .AllowAnyMethod(); | ||||||
|                       }); |                       }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| // 1. Registramos nuestra fábrica de conexiones a la BD. | // Registros de servicios (esto está perfecto) | ||||||
| builder.Services.AddSingleton<IDbConnectionFactory, SqlConnectionFactory>(); | 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<ICotizacionGanadoRepository, CotizacionGanadoRepository>(); | ||||||
| builder.Services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); | builder.Services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); | ||||||
| builder.Services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); | builder.Services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); | ||||||
| builder.Services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>(); | builder.Services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>(); | ||||||
|  |  | ||||||
| // 3. Configurar FluentMigrator | // Configuración de FluentMigrator (perfecto) | ||||||
| builder.Services | builder.Services | ||||||
|     .AddFluentMigratorCore() |     .AddFluentMigratorCore() | ||||||
|     .ConfigureRunner(rb => rb |     .ConfigureRunner(rb => rb | ||||||
|         // Usar el conector para SQL Server |  | ||||||
|         .AddSqlServer() |         .AddSqlServer() | ||||||
|         // Obtener la cadena de conexión desde appsettings.json |  | ||||||
|         .WithGlobalConnectionString(builder.Configuration.GetConnectionString("DefaultConnection")) |         .WithGlobalConnectionString(builder.Configuration.GetConnectionString("DefaultConnection")) | ||||||
|         // Definir el ensamblado (proyecto) que contiene las migraciones |  | ||||||
|         .ScanIn(typeof(CreateInitialTables).Assembly).For.Migrations()) |         .ScanIn(typeof(CreateInitialTables).Assembly).For.Migrations()) | ||||||
|     // Habilitar el logging para ver qué hacen las migraciones en la consola |  | ||||||
|     .AddLogging(lb => lb.AddFluentMigratorConsole()); |     .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.AddControllers(); | ||||||
| builder.Services.AddEndpointsApiExplorer(); | builder.Services.AddEndpointsApiExplorer(); | ||||||
| builder.Services.AddSwaggerGen(); | builder.Services.AddSwaggerGen(); | ||||||
|  |  | ||||||
| var app = builder.Build(); | var app = builder.Build(); | ||||||
|  |  | ||||||
| // 4. Ejecutar las migraciones al iniciar la aplicación (ideal para desarrollo y despliegues sencillos) | // Ejecución de migraciones (perfecto) | ||||||
| // Obtenemos el "scope" de los servicios para poder solicitar el MigrationRunner |  | ||||||
| using (var scope = app.Services.CreateScope()) | using (var scope = app.Services.CreateScope()) | ||||||
| { | { | ||||||
|     var migrationRunner = scope.ServiceProvider.GetRequiredService<IMigrationRunner>(); |     var migrationRunner = scope.ServiceProvider.GetRequiredService<IMigrationRunner>(); | ||||||
|     // Ejecuta las migraciones pendientes |  | ||||||
|     migrationRunner.MigrateUp(); |     migrationRunner.MigrateUp(); | ||||||
| } | } | ||||||
|  |  | ||||||
| // Configure the HTTP request pipeline. | // Pipeline de HTTP (perfecto) | ||||||
| if (app.Environment.IsDevelopment()) | if (app.Environment.IsDevelopment()) | ||||||
| { | { | ||||||
|     app.UseSwagger(); |     app.UseSwagger(); | ||||||
| @@ -75,11 +69,7 @@ if (app.Environment.IsDevelopment()) | |||||||
| } | } | ||||||
|  |  | ||||||
| app.UseHttpsRedirection(); | app.UseHttpsRedirection(); | ||||||
|  |  | ||||||
| app.UseCors(MyAllowSpecificOrigins); | app.UseCors(MyAllowSpecificOrigins); | ||||||
|  |  | ||||||
| app.UseAuthorization(); | app.UseAuthorization(); | ||||||
|  |  | ||||||
| app.MapControllers(); | app.MapControllers(); | ||||||
|  |  | ||||||
| app.Run(); | 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": { |   "ConnectionStrings": { | ||||||
|     "DefaultConnection": "" |     "DefaultConnection": "" | ||||||
|   }, |   }, | ||||||
|  |   "Schedules": { | ||||||
|  |     "MercadoAgroganadero": "0 11 * * 1-5", | ||||||
|  |     "BCR": "30 11 * * 1-5", | ||||||
|  |     "Bolsas": "10 11-17 * * 1-5" | ||||||
|  |   }, | ||||||
|   "ApiKeys": { |   "ApiKeys": { | ||||||
|     "Finnhub": "", |     "Finnhub": "", | ||||||
|     "Bcr": { |     "Bcr": { | ||||||
|       "Key": "", |       "Key": "", | ||||||
|       "Secret": "" |       "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 _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | ||||||
|                 await UpdateSourceInfoAsync(); |                 await UpdateSourceInfoAsync(); | ||||||
| @@ -123,8 +127,8 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|         private async Task<string?> GetAuthTokenAsync(HttpClient client) |         private async Task<string?> GetAuthTokenAsync(HttpClient client) | ||||||
|         { |         { | ||||||
|             var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/Login"); |             var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/Login"); | ||||||
|             request.Headers.Add("api_key", Environment.GetEnvironmentVariable("BCR_API_KEY")); |             request.Headers.Add("api_key", _configuration["ApiKeys:Bcr:Key"]); | ||||||
|             request.Headers.Add("secret", Environment.GetEnvironmentVariable("BCR_API_SECRET")); |             request.Headers.Add("secret", _configuration["ApiKeys:Bcr:Secret"]); | ||||||
|  |  | ||||||
|             var response = await client.SendAsync(request); |             var response = await client.SendAsync(request); | ||||||
|             response.EnsureSuccessStatusCode(); |             response.EnsureSuccessStatusCode(); | ||||||
|   | |||||||
| @@ -31,12 +31,11 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             IFuenteDatoRepository fuenteDatoRepository, |             IFuenteDatoRepository fuenteDatoRepository, | ||||||
|             ILogger<FinnhubDataFetcher> logger) |             ILogger<FinnhubDataFetcher> logger) | ||||||
|         { |         { | ||||||
|             var apiKey = Environment.GetEnvironmentVariable("FINNHUB_API_KEY"); |             var apiKey = configuration["ApiKeys:Finnhub"]; | ||||||
|             if (string.IsNullOrEmpty(apiKey)) |             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); |             _client = new FinnhubClient(httpClientFactory.CreateClient("FinnhubDataFetcher"), apiKey); | ||||||
|             _cotizacionRepository = cotizacionRepository; |             _cotizacionRepository = cotizacionRepository; | ||||||
|             _fuenteDatoRepository = fuenteDatoRepository; |             _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 _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | ||||||
|             await UpdateSourceInfoAsync(); |             await UpdateSourceInfoAsync(); | ||||||
|   | |||||||
| @@ -10,7 +10,6 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|     { |     { | ||||||
|         public string SourceName => "MercadoAgroganadero"; |         public string SourceName => "MercadoAgroganadero"; | ||||||
|         private const string DataUrl = "https://www.mercadoagroganadero.com.ar/dll/hacienda6.dll/haciinfo000225"; |         private const string DataUrl = "https://www.mercadoagroganadero.com.ar/dll/hacienda6.dll/haciinfo000225"; | ||||||
|  |  | ||||||
|         private readonly IHttpClientFactory _httpClientFactory; |         private readonly IHttpClientFactory _httpClientFactory; | ||||||
|         private readonly ICotizacionGanadoRepository _cotizacionRepository; |         private readonly ICotizacionGanadoRepository _cotizacionRepository; | ||||||
|         private readonly IFuenteDatoRepository _fuenteDatoRepository; |         private readonly IFuenteDatoRepository _fuenteDatoRepository; | ||||||
| @@ -37,13 +36,18 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|                 var htmlContent = await GetHtmlContentAsync(); |                 var htmlContent = await GetHtmlContentAsync(); | ||||||
|                 if (string.IsNullOrEmpty(htmlContent)) |                 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."); |                     return (false, "No se pudo obtener el contenido HTML."); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 var cotizaciones = ParseHtmlToEntities(htmlContent); |                 var cotizaciones = ParseHtmlToEntities(htmlContent); | ||||||
|  |  | ||||||
|                 if (!cotizaciones.Any()) |                 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); |                 await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | ||||||
| @@ -54,6 +58,7 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             } |             } | ||||||
|             catch (Exception ex) |             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); |                 _logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName); | ||||||
|                 return (false, $"Error: {ex.Message}"); |                 return (false, $"Error: {ex.Message}"); | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -10,18 +10,18 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|         public string SourceName => "YahooFinance"; |         public string SourceName => "YahooFinance"; | ||||||
|         private readonly List<string> _tickers = new() { |         private readonly List<string> _tickers = new() { | ||||||
|             "^GSPC", // Índice S&P 500 |             "^GSPC", // Índice S&P 500 | ||||||
|             "^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.BA",  |             "^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.BA", | ||||||
|             "TECO2.BA", "EDN.BA", "CRES.BA", "TXAR.BA", "MIRG.BA",  |             "TECO2.BA", "EDN.BA", "CRES.BA", "TXAR.BA", "MIRG.BA", | ||||||
|             "CEPU.BA", "LOMA.BA", "VALO.BA", "MELI.BA" |             "CEPU.BA", "LOMA.BA", "VALO.BA", "MELI.BA" | ||||||
|         }; |         }; | ||||||
|          |  | ||||||
|         private readonly ICotizacionBolsaRepository _cotizacionRepository; |         private readonly ICotizacionBolsaRepository _cotizacionRepository; | ||||||
|         private readonly IFuenteDatoRepository _fuenteDatoRepository; |         private readonly IFuenteDatoRepository _fuenteDatoRepository; | ||||||
|         private readonly ILogger<YahooFinanceDataFetcher> _logger; |         private readonly ILogger<YahooFinanceDataFetcher> _logger; | ||||||
|  |  | ||||||
|         public YahooFinanceDataFetcher( |         public YahooFinanceDataFetcher( | ||||||
|             ICotizacionBolsaRepository cotizacionRepository,  |             ICotizacionBolsaRepository cotizacionRepository, | ||||||
|             IFuenteDatoRepository fuenteDatoRepository,  |             IFuenteDatoRepository fuenteDatoRepository, | ||||||
|             ILogger<YahooFinanceDataFetcher> logger) |             ILogger<YahooFinanceDataFetcher> logger) | ||||||
|         { |         { | ||||||
|             _cotizacionRepository = cotizacionRepository; |             _cotizacionRepository = cotizacionRepository; | ||||||
| @@ -40,7 +40,7 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|                 foreach (var sec in securities.Values) |                 foreach (var sec in securities.Values) | ||||||
|                 { |                 { | ||||||
|                     if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue; |                     if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue; | ||||||
|                      |  | ||||||
|                     string mercado = sec.Symbol.EndsWith(".BA") || sec.Symbol == "^MERV" ? "Local" : "EEUU"; |                     string mercado = sec.Symbol.EndsWith(".BA") || sec.Symbol == "^MERV" ? "Local" : "EEUU"; | ||||||
|  |  | ||||||
|                     cotizaciones.Add(new CotizacionBolsa |                     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 _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | ||||||
|                 await UpdateSourceInfoAsync(); |                 await UpdateSourceInfoAsync(); | ||||||
| @@ -70,7 +74,7 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|                 return (false, $"Error: {ex.Message}"); |                 return (false, $"Error: {ex.Message}"); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|          |  | ||||||
|         private async Task UpdateSourceInfoAsync() |         private async Task UpdateSourceInfoAsync() | ||||||
|         { |         { | ||||||
|             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); |             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ | |||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <PackageReference Include="AngleSharp" Version="1.3.0" /> |     <PackageReference Include="AngleSharp" Version="1.3.0" /> | ||||||
|     <PackageReference Include="Dapper" Version="2.1.66" /> |     <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.Data.SqlClient" Version="6.0.2" /> | ||||||
|     <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" /> |     <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" /> | ||||||
|     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" /> |     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" /> | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| using Mercados.Infrastructure.Persistence; | using Mercados.Infrastructure.Persistence; | ||||||
| using Microsoft.Data.SqlClient; | using Microsoft.Data.SqlClient; | ||||||
| using Microsoft.Extensions.Configuration; | using Microsoft.Extensions.Configuration; // Asegúrate de que este using esté | ||||||
| using System.Data; | using System.Data; | ||||||
|  |  | ||||||
| namespace Mercados.Infrastructure | namespace Mercados.Infrastructure | ||||||
| @@ -11,14 +11,14 @@ namespace Mercados.Infrastructure | |||||||
|  |  | ||||||
|     public SqlConnectionFactory(IConfiguration configuration) |     public SqlConnectionFactory(IConfiguration configuration) | ||||||
|     { |     { | ||||||
|       // Leemos directamente de la variable de entorno |       // Variable de entorno 'DB_CONNECTION_STRING' si está disponible, | ||||||
|       _connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING") |       // o el valor de appsettings.json si no lo está. | ||||||
|           ?? throw new ArgumentNullException(nameof(configuration), "La variable de entorno 'DB_CONNECTION_STRING' no fue encontrada."); |       _connectionString = configuration.GetConnectionString("DefaultConnection") | ||||||
|  |           ?? throw new ArgumentNullException(nameof(configuration), "La cadena de conexión 'DefaultConnection' no fue encontrada."); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public IDbConnection CreateConnection() |     public IDbConnection CreateConnection() | ||||||
|     { |     { | ||||||
|       // Dapper se encargará de abrir y cerrar la conexión automáticamente. |  | ||||||
|       return new SqlConnection(_connectionString); |       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 Cronos; | ||||||
|  | using Mercados.Infrastructure.DataFetchers; | ||||||
|  | using Mercados.Infrastructure.Services; | ||||||
|  | using Microsoft.Extensions.Configuration; | ||||||
|  |  | ||||||
| namespace Mercados.Worker | namespace Mercados.Worker | ||||||
| { | { | ||||||
| @@ -12,31 +14,47 @@ namespace Mercados.Worker | |||||||
|         private readonly ILogger<DataFetchingService> _logger; |         private readonly ILogger<DataFetchingService> _logger; | ||||||
|         private readonly IServiceProvider _serviceProvider; |         private readonly IServiceProvider _serviceProvider; | ||||||
|         private readonly TimeZoneInfo _argentinaTimeZone; |         private readonly TimeZoneInfo _argentinaTimeZone; | ||||||
|         private readonly IConfiguration _configuration; |  | ||||||
|  |  | ||||||
|         // Diccionario para rastrear la última vez que se ejecutó una tarea diaria |         // Almacenamos las expresiones Cron parseadas para no tener que hacerlo en cada ciclo. | ||||||
|         // y evitar que se ejecute múltiples veces si el servicio se reinicia. |         private readonly CronExpression _agroSchedule; | ||||||
|         private readonly Dictionary<string, DateTime> _lastDailyRun = new(); |         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; |             _logger = logger; | ||||||
|             _serviceProvider = serviceProvider; |             _serviceProvider = serviceProvider; | ||||||
|             _configuration = configuration; |  | ||||||
|              |  | ||||||
|             // Se define explícitamente la zona horaria de Argentina. |             // 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 |             try | ||||||
|             { |             { | ||||||
|                 // El ID estándar para Linux y macOS |  | ||||||
|                 _argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"); |                 _argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"); | ||||||
|             } |             } | ||||||
|             catch (TimeZoneNotFoundException) |             catch (TimeZoneNotFoundException) | ||||||
|             { |             { | ||||||
|                 // El ID equivalente para Windows |  | ||||||
|                 _argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time"); |                 _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> |         /// <summary> | ||||||
| @@ -46,106 +64,58 @@ namespace Mercados.Worker | |||||||
|         { |         { | ||||||
|             _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); |             _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); | ||||||
|  |  | ||||||
|             // Se recomienda una ejecución inicial para poblar la base de datos inmediatamente |             // Ejecutamos una vez al inicio para tener datos frescos inmediatamente. | ||||||
|             // al iniciar el servicio, en lugar de esperar al primer horario programado. |             await RunAllFetchersAsync(stoppingToken); | ||||||
|             //await RunAllFetchersAsync(stoppingToken); |  | ||||||
|  |  | ||||||
|             // PeriodicTimer es una forma moderna y eficiente de crear un bucle de "tic-tac" |             // Calculamos las primeras ejecuciones programadas al arrancar. | ||||||
|             // sin bloquear un hilo con Task.Delay. |             var utcNow = DateTime.UtcNow; | ||||||
|             using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); |             _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)) |             while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) | ||||||
|             { |             { | ||||||
|                 await RunScheduledTasksAsync(stoppingToken); |                 utcNow = DateTime.UtcNow; | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         /// <summary> |                 // Comprobamos si ha llegado el momento de la próxima ejecución para cada tarea. | ||||||
|         /// Revisa la hora actual y ejecuta las tareas que coincidan con su horario programado. |                 if (_nextAgroRun.HasValue && utcNow >= _nextAgroRun.Value) | ||||||
|         /// </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)) |  | ||||||
|                 { |                 { | ||||||
|                     await RunFetcherByNameAsync(taskName, stoppingToken); |                     await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken); | ||||||
|                     _lastDailyRun[taskName] = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _argentinaTimeZone).Date; |                     // 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> |         /// <summary> | ||||||
|         /// Comprueba y ejecuta una tarea que puede correr múltiples veces al día. |         /// Ejecuta un fetcher específico por su nombre, gestionando el scope de DI y las notificaciones. | ||||||
|         /// </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). |  | ||||||
|         /// </summary> |         /// </summary> | ||||||
|         private async Task RunFetcherByNameAsync(string sourceName, CancellationToken stoppingToken) |         private async Task RunFetcherByNameAsync(string sourceName, CancellationToken stoppingToken) | ||||||
|         { |         { | ||||||
|             if (stoppingToken.IsCancellationRequested) return; |             if (stoppingToken.IsCancellationRequested) return; | ||||||
|  |  | ||||||
|             _logger.LogInformation("Intentando ejecutar fetcher: {sourceName}", sourceName); |             _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(); |             using var scope = _serviceProvider.CreateScope(); | ||||||
|             var fetchers = scope.ServiceProvider.GetRequiredService<IEnumerable<IDataFetcher>>(); |             var fetchers = scope.ServiceProvider.GetRequiredService<IEnumerable<IDataFetcher>>(); | ||||||
|             var fetcher = fetchers.FirstOrDefault(f => f.SourceName.Equals(sourceName, StringComparison.OrdinalIgnoreCase)); |             var fetcher = fetchers.FirstOrDefault(f => f.SourceName.Equals(sourceName, StringComparison.OrdinalIgnoreCase)); | ||||||
| @@ -155,7 +125,19 @@ namespace Mercados.Worker | |||||||
|                 var (success, message) = await fetcher.FetchDataAsync(); |                 var (success, message) = await fetcher.FetchDataAsync(); | ||||||
|                 if (!success) |                 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 |             else | ||||||
| @@ -165,31 +147,35 @@ namespace Mercados.Worker | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |         /// <summary> | ||||||
|         /// Ejecuta todos los fetchers al iniciar el servicio. Esto es útil para poblar |         /// Ejecuta todos los fetchers en paralelo al iniciar el servicio. | ||||||
|         /// la base de datos inmediatamente al arrancar el worker. |  | ||||||
|         /// </summary> |         /// </summary> | ||||||
|         /* |  | ||||||
|         private async Task RunAllFetchersAsync(CancellationToken stoppingToken) |         private async Task RunAllFetchersAsync(CancellationToken stoppingToken) | ||||||
|         { |         { | ||||||
|             _logger.LogInformation("Ejecutando todos los fetchers al iniciar en paralelo..."); |             _logger.LogInformation("Ejecutando todos los fetchers al iniciar en paralelo..."); | ||||||
|             using var scope = _serviceProvider.CreateScope(); |             using var scope = _serviceProvider.CreateScope(); | ||||||
|             var fetchers = scope.ServiceProvider.GetRequiredService<IEnumerable<IDataFetcher>>(); |             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)); | ||||||
|             var tasks = fetchers.Select(fetcher => RunFetcherByNameAsync(fetcher.SourceName, stoppingToken)).ToList(); |  | ||||||
|              |  | ||||||
|             // Ejecutamos todo y esperamos |  | ||||||
|             await Task.WhenAll(tasks); |             await Task.WhenAll(tasks); | ||||||
|  |  | ||||||
|             _logger.LogInformation("Ejecución inicial de todos los fetchers completada."); |             _logger.LogInformation("Ejecución inicial de todos los fetchers completada."); | ||||||
|         } |         } | ||||||
|         */ |  | ||||||
|          |  | ||||||
|         #region Funciones de Ayuda para la Planificación |         #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 |         #endregion | ||||||
|   | |||||||
| @@ -9,7 +9,6 @@ | |||||||
|  |  | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <PackageReference Include="Cronos" Version="0.11.0" /> |     <PackageReference Include="Cronos" Version="0.11.0" /> | ||||||
|     <PackageReference Include="DotNetEnv" Version="3.1.1" /> |  | ||||||
|     <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.5" /> |     <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.5" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -6,90 +6,43 @@ using Mercados.Infrastructure.Persistence.Repositories; | |||||||
| using Mercados.Worker; | using Mercados.Worker; | ||||||
| using Polly; | using Polly; | ||||||
| using Polly.Extensions.Http; | 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) | IHost host = Host.CreateDefaultBuilder(args) | ||||||
|     .ConfigureServices((hostContext, services) => |     .ConfigureServices((hostContext, services) => | ||||||
|     { |     { | ||||||
|         // Obtenemos la configuración desde el host builder para usarla aquí. |  | ||||||
|         IConfiguration configuration = hostContext.Configuration; |         IConfiguration configuration = hostContext.Configuration; | ||||||
|  |  | ||||||
|         // --- 1. Registro de Servicios de Infraestructura --- |         // El resto del código no cambia. IConfiguration recogerá automáticamente | ||||||
|  |         // las variables de entorno que cargamos correctamente. | ||||||
|         // 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<IDbConnectionFactory, SqlConnectionFactory>(); |         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<ICotizacionGanadoRepository, CotizacionGanadoRepository>(); | ||||||
|         services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); |         services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); | ||||||
|         services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); |         services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); | ||||||
|         services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>(); |         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, MercadoAgroFetcher>(); | ||||||
|         services.AddScoped<IDataFetcher, BcrDataFetcher>(); |         services.AddScoped<IDataFetcher, BcrDataFetcher>(); | ||||||
|         services.AddScoped<IDataFetcher, FinnhubDataFetcher>(); |         services.AddScoped<IDataFetcher, FinnhubDataFetcher>(); | ||||||
|         services.AddScoped<IDataFetcher, YahooFinanceDataFetcher>(); |         services.AddScoped<IDataFetcher, YahooFinanceDataFetcher>(); | ||||||
|  |  | ||||||
|         // El cliente HTTP es fundamental para hacer llamadas a APIs externas. |         services.AddHttpClient("MercadoAgroFetcher").AddPolicyHandler(GetRetryPolicy()); | ||||||
|         // Le damos un nombre al cliente de Finnhub para cumplir con los requisitos de su constructor. |         services.AddHttpClient("BcrDataFetcher").AddPolicyHandler(GetRetryPolicy()); | ||||||
|         //services.AddHttpClient("Finnhub"); |         services.AddHttpClient("FinnhubDataFetcher").AddPolicyHandler(GetRetryPolicy()); | ||||||
|          |  | ||||||
|          // 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()); |  | ||||||
|  |  | ||||||
|         // 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>(); |         services.AddHostedService<DataFetchingService>(); | ||||||
|     }) |     }) | ||||||
|     .Build(); |     .Build(); | ||||||
|  |  | ||||||
| // Esta función define nuestra política de reintentos. |  | ||||||
| static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy() | static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy() | ||||||
| { | { | ||||||
|     // Polly.Extensions.Http nos da este método conveniente. |  | ||||||
|     return HttpPolicyExtensions |     return HttpPolicyExtensions | ||||||
|         // Maneja errores de red transitorios O códigos de estado de servidor que indican un problema temporal. |         .HandleTransientHttpError() | ||||||
|         .HandleTransientHttpError()  |         .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.RequestTimeout) | ||||||
|         // También maneja el error 408 Request Timeout |         .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), | ||||||
|         .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)),  |  | ||||||
|             onRetry: (outcome, timespan, retryAttempt, context) => |             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}"); |                 Console.WriteLine($"[Polly] Reintentando petición... Intento {retryAttempt}. Esperando {timespan.TotalSeconds}s. Causa: {outcome.Exception?.Message ?? outcome.Result.ReasonPhrase}"); | ||||||
|             }); |             }); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -20,5 +20,13 @@ | |||||||
|       "Key": "", |       "Key": "", | ||||||
|       "Secret": "" |       "Secret": "" | ||||||
|     } |     } | ||||||
|  |   }, | ||||||
|  |   "SmtpSettings": { | ||||||
|  |     "Host": "", | ||||||
|  |     "Port": 587, | ||||||
|  |     "User": "", | ||||||
|  |     "Pass": "", | ||||||
|  |     "SenderName": "Servicio de Mercados", | ||||||
|  |     "Recipient": "" | ||||||
|   } |   } | ||||||
| } | } | ||||||
		Reference in New Issue
	
	Block a user