feat(Worker): Implementa servicio de notificación para alertas de fallos críticos - Se remueve .env y se utilizan appsettings.Development.json y User Secrets
This commit is contained in:
16
.env
16
.env
@@ -1,7 +1,15 @@
|
|||||||
# --- Conexión a la Base de Datos ---
|
# --- Conexión a la Base de Datos ---
|
||||||
DB_CONNECTION_STRING="Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;"
|
ConnectionStrings__DefaultConnection="Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;"
|
||||||
|
|
||||||
# --- Claves de APIs Externas ---
|
# --- Claves de APIs Externas ---
|
||||||
FINNHUB_API_KEY="cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30"
|
ApiKeys__Finnhub="cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30"
|
||||||
BCR_API_KEY="D1782A51-A5FD-EF11-9445-00155D09E201"
|
ApiKeys__Bcr__Key="D1782A51-A5FD-EF11-9445-00155D09E201"
|
||||||
BCR_API_SECRET="da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3"
|
ApiKeys__Bcr__Secret="da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3"
|
||||||
|
|
||||||
|
# --- Configuración de Email para Alertas ---
|
||||||
|
SMTP_HOST="mail.eldia.com"
|
||||||
|
SMTP_PORT="587"
|
||||||
|
SMTP_USER="alertas@eldia.com"
|
||||||
|
SMTP_PASS="@Alertas713550@"
|
||||||
|
EMAIL_SENDER_NAME="Servicio de Mercados"
|
||||||
|
EMAIL_RECIPIENT="dmolinari@eldia.com"
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
|
using DotNetEnv;
|
||||||
using FluentMigrator.Runner;
|
using FluentMigrator.Runner;
|
||||||
using Mercados.Database.Migrations;
|
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 DotNetEnv.Configuration;
|
||||||
|
|
||||||
// Carga las variables de entorno desde el archivo .env en la raíz de la solución.
|
|
||||||
DotNetEnv.Env.Load();
|
var envFilePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../.env"));
|
||||||
|
|
||||||
|
// Cargamos el archivo .env desde la ruta explícita.
|
||||||
|
if (!Env.Load(envFilePath).Any())
|
||||||
|
{
|
||||||
|
Console.WriteLine($"ADVERTENCIA: No se pudo encontrar el archivo .env en la ruta: {envFilePath}");
|
||||||
|
}
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -19,55 +26,45 @@ 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());
|
||||||
|
|
||||||
|
// Servicios del contenedor estándar (perfecto)
|
||||||
// Add services to the container.
|
|
||||||
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 +72,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();
|
||||||
@@ -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["SMTP_HOST"];
|
||||||
|
var smtpPort = int.Parse(_configuration["SMTP_PORT"] ?? "587");
|
||||||
|
var smtpUser = _configuration["SMTP_USER"];
|
||||||
|
var smtpPass = _configuration["SMTP_PASS"];
|
||||||
|
var senderName = _configuration["EMAIL_SENDER_NAME"];
|
||||||
|
var recipient = _configuration["EMAIL_RECIPIENT"];
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(smtpHost) || string.IsNullOrEmpty(smtpUser) || string.IsNullOrEmpty(smtpPass))
|
||||||
|
{
|
||||||
|
_logger.LogError("La configuración SMTP está incompleta. No se puede enviar el email de alerta.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usamos la hora actual en UTC para el evento.
|
||||||
|
var displayTime = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Buscamos la zona horaria de Argentina
|
||||||
|
TimeZoneInfo argentinaTimeZone;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires");
|
||||||
|
}
|
||||||
|
catch (TimeZoneNotFoundException)
|
||||||
|
{
|
||||||
|
argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertimos la hora UTC a la hora local de Argentina
|
||||||
|
var localTime = TimeZoneInfo.ConvertTimeFromUtc(displayTime, argentinaTimeZone);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var email = new MimeMessage();
|
||||||
|
email.From.Add(new MailboxAddress(senderName, smtpUser));
|
||||||
|
email.To.Add(MailboxAddress.Parse(recipient));
|
||||||
|
email.Subject = subject;
|
||||||
|
|
||||||
|
// Creamos un cuerpo de correo un poco más elaborado
|
||||||
|
var builder = new BodyBuilder
|
||||||
|
{
|
||||||
|
HtmlBody = $@"
|
||||||
|
<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
|
||||||
|
|||||||
@@ -6,90 +6,62 @@ 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;
|
||||||
|
using DotNetEnv;
|
||||||
|
using DotNetEnv.Configuration;
|
||||||
|
|
||||||
// Carga las variables de entorno desde el archivo .env en la raíz de la solución.
|
var envFilePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../.env"));
|
||||||
DotNetEnv.Env.Load();
|
|
||||||
|
// Cargamos el archivo .env desde la ruta explícita.
|
||||||
|
// Si no lo encuentra, Load retornará false.
|
||||||
|
if (!Env.Load(envFilePath).Any())
|
||||||
|
{
|
||||||
|
Console.WriteLine($"ADVERTENCIA: No se pudo encontrar el archivo .env en la ruta: {envFilePath}");
|
||||||
|
}
|
||||||
|
|
||||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
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í.
|
// La línea 'config.AddDotNetEnv(optional: true);' ha sido eliminada.
|
||||||
|
|
||||||
IConfiguration configuration = hostContext.Configuration;
|
IConfiguration configuration = hostContext.Configuration;
|
||||||
|
|
||||||
// --- 1. Registro de Servicios de Infraestructura ---
|
// --- 1. Registro de Servicios de Infraestructura ---
|
||||||
|
|
||||||
// Registramos la fábrica de conexiones a la BD. Es un Singleton porque
|
|
||||||
// solo necesita ser creada una vez para leer la cadena de conexión.
|
|
||||||
services.AddSingleton<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, ConsoleNotificationService>();
|
||||||
|
services.AddScoped<INotificationService, EmailNotificationService>();
|
||||||
|
|
||||||
// --- 2. Registro de los Data Fetchers ---
|
// --- 2. Registro de los Data Fetchers ---
|
||||||
|
// Descomentados para la versión final y funcional.
|
||||||
// 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.
|
// --- 3. Configuración de Clientes HTTP con Polly ---
|
||||||
// Le damos un nombre al cliente de Finnhub para cumplir con los requisitos de su constructor.
|
services.AddHttpClient("MercadoAgroFetcher").AddPolicyHandler(GetRetryPolicy());
|
||||||
//services.AddHttpClient("Finnhub");
|
services.AddHttpClient("BcrDataFetcher").AddPolicyHandler(GetRetryPolicy());
|
||||||
|
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
|
// --- 4. Registro del Worker Principal ---
|
||||||
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}");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user