feat(backend): Complete backend setup with API and Worker services
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
<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>b004094a-ecbf-4bb4-aa67-af634a6090d9</UserSecretsId>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
55
src/Clima.Api/Controllers/WeatherController.cs
Normal file
55
src/Clima.Api/Controllers/WeatherController.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
using Clima.Core.Entities;
|
||||||
|
using Clima.Infrastructure.Persistence.Repositories;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Clima.Api.Controllers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Provee endpoints para acceder a los datos del pronóstico del tiempo.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class WeatherController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IPronosticoRepository _pronosticoRepository;
|
||||||
|
private readonly ILogger<WeatherController> _logger;
|
||||||
|
|
||||||
|
public WeatherController(
|
||||||
|
IPronosticoRepository pronosticoRepository,
|
||||||
|
ILogger<WeatherController> logger)
|
||||||
|
{
|
||||||
|
_pronosticoRepository = pronosticoRepository;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Obtiene el pronóstico de 5 días para una estación meteorológica específica.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="nombreEstacion">El nombre de la estación (ej. "LA_PLATA_AERO").</param>
|
||||||
|
/// <returns>Una lista de pronósticos ordenados por fecha.</returns>
|
||||||
|
[HttpGet("{nombreEstacion}")]
|
||||||
|
[ProducesResponseType(typeof(IEnumerable<Pronostico>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<IActionResult> GetForecastByStation(string nombreEstacion)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pronosticos = await _pronosticoRepository.ObtenerPorEstacionAsync(nombreEstacion);
|
||||||
|
|
||||||
|
if (pronosticos == null || !pronosticos.Any())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No se encontraron datos de pronóstico para la estación: {Estacion}", nombreEstacion);
|
||||||
|
return NotFound($"No se encontraron datos para la estación: {nombreEstacion}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(pronosticos);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error al obtener el pronóstico para la estación: {Estacion}", nombreEstacion);
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError, "Ocurrió un error interno al procesar la solicitud.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,41 +1,50 @@
|
|||||||
|
using Clima.Database.Migrations;
|
||||||
|
using Clima.Infrastructure.Persistence;
|
||||||
|
using Clima.Infrastructure.Persistence.Repositories;
|
||||||
|
using FluentMigrator.Runner;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Add services to the container.
|
// --- Registro de Servicios ---
|
||||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
|
||||||
builder.Services.AddOpenApi();
|
// 1. Fábrica de Conexiones
|
||||||
|
builder.Services.AddSingleton<IDbConnectionFactory, SqlConnectionFactory>();
|
||||||
|
|
||||||
|
// 2. Repositorio
|
||||||
|
builder.Services.AddScoped<IPronosticoRepository, PronosticoRepository>();
|
||||||
|
|
||||||
|
// 3. FluentMigrator para la gestión de la base de datos
|
||||||
|
builder.Services
|
||||||
|
.AddFluentMigratorCore()
|
||||||
|
.ConfigureRunner(rb => rb
|
||||||
|
.AddSqlServer()
|
||||||
|
.WithGlobalConnectionString(builder.Configuration.GetConnectionString("DefaultConnection"))
|
||||||
|
.ScanIn(typeof(CreatePronosticoTable).Assembly).For.Migrations())
|
||||||
|
.AddLogging(lb => lb.AddFluentMigratorConsole());
|
||||||
|
|
||||||
|
// 4. Servicios estándar de la API
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// --- Pipeline de Ejecución ---
|
||||||
|
|
||||||
|
// Ejecutar migraciones al inicio para crear/actualizar la base de datos
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var migrationRunner = scope.ServiceProvider.GetRequiredService<IMigrationRunner>();
|
||||||
|
migrationRunner.MigrateUp();
|
||||||
|
}
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
app.UseAuthorization();
|
||||||
var summaries = new[]
|
app.MapControllers();
|
||||||
{
|
|
||||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
|
||||||
};
|
|
||||||
|
|
||||||
app.MapGet("/weatherforecast", () =>
|
|
||||||
{
|
|
||||||
var forecast = Enumerable.Range(1, 5).Select(index =>
|
|
||||||
new WeatherForecast
|
|
||||||
(
|
|
||||||
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
|
||||||
Random.Shared.Next(-20, 55),
|
|
||||||
summaries[Random.Shared.Next(summaries.Length)]
|
|
||||||
))
|
|
||||||
.ToArray();
|
|
||||||
return forecast;
|
|
||||||
})
|
|
||||||
.WithName("GetWeatherForecast");
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
|
|
||||||
{
|
|
||||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,5 +5,8 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": ""
|
||||||
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
namespace Clima.Core.Entities
|
namespace Clima.Core.Entities
|
||||||
{
|
{
|
||||||
public class Pronostico
|
public class Pronostico
|
||||||
{
|
{
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
public string Estacion { get; set; } = string.Empty;
|
public string Estacion { get; set; } = string.Empty;
|
||||||
public DateTime FechaHora { get; set; }
|
public DateTime FechaHora { get; set; }
|
||||||
public decimal TemperaturaC { get; set; }
|
public decimal TemperaturaC { get; set; }
|
||||||
public int VientoDirGrados { get; set; }
|
public int VientoDirGrados { get; set; }
|
||||||
public int VientoKmh { get; set; }
|
public int VientoKmh { get; set; }
|
||||||
public decimal PrecipitacionMm { get; set; }
|
public decimal PrecipitacionMm { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
8
src/Clima.Infrastructure/DataFetchers/IDataFetcher.cs
Normal file
8
src/Clima.Infrastructure/DataFetchers/IDataFetcher.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Clima.Infrastructure.DataFetchers
|
||||||
|
{
|
||||||
|
public interface IDataFetcher
|
||||||
|
{
|
||||||
|
string SourceName { get; }
|
||||||
|
Task<(bool Success, string Message)> FetchDataAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/Clima.Infrastructure/DataFetchers/SmnEtlFetcher.cs
Normal file
179
src/Clima.Infrastructure/DataFetchers/SmnEtlFetcher.cs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Clima.Core.Entities;
|
||||||
|
using Clima.Infrastructure.Persistence.Repositories;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Clima.Infrastructure.DataFetchers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Realiza el proceso ETL completo para los datos de pronóstico de 5 días del SMN.
|
||||||
|
/// </summary>
|
||||||
|
public class SmnEtlFetcher : IDataFetcher
|
||||||
|
{
|
||||||
|
public string SourceName => "SMN_Forecast_5_Days";
|
||||||
|
private const string DataUrl = "https://ssl.smn.gob.ar/dpd/zipopendata.php?dato=pron5d";
|
||||||
|
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IPronosticoRepository _pronosticoRepository;
|
||||||
|
private readonly ILogger<SmnEtlFetcher> _logger;
|
||||||
|
|
||||||
|
public SmnEtlFetcher(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IPronosticoRepository pronosticoRepository,
|
||||||
|
ILogger<SmnEtlFetcher> logger)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_pronosticoRepository = pronosticoRepository;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, string Message)> FetchDataAsync()
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Iniciando proceso ETL para {SourceName}.", SourceName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. EXTRACCIÓN (E)
|
||||||
|
_logger.LogInformation("Descargando archivo ZIP desde la fuente de datos...");
|
||||||
|
var zipBytes = await DownloadZipFileAsync();
|
||||||
|
if (zipBytes == null || zipBytes.Length == 0)
|
||||||
|
{
|
||||||
|
return (false, "La descarga del archivo ZIP falló o el archivo está vacío.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. TRANSFORMACIÓN (T)
|
||||||
|
_logger.LogInformation("Parseando los datos del pronóstico desde el archivo de texto...");
|
||||||
|
var pronosticosPorEstacion = ParseTxtContent(zipBytes);
|
||||||
|
if (!pronosticosPorEstacion.Any())
|
||||||
|
{
|
||||||
|
return (true, "Proceso completado, pero no se encontraron datos de pronóstico válidos para procesar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. CARGA (L)
|
||||||
|
_logger.LogInformation("Actualizando la base de datos con {Count} estaciones.", pronosticosPorEstacion.Count);
|
||||||
|
foreach (var kvp in pronosticosPorEstacion)
|
||||||
|
{
|
||||||
|
var estacion = kvp.Key;
|
||||||
|
var pronosticos = kvp.Value;
|
||||||
|
await _pronosticoRepository.ReemplazarPronosticosPorEstacionAsync(estacion, pronosticos);
|
||||||
|
_logger.LogInformation("Datos para la estación '{Estacion}' actualizados ({Count} registros).", estacion, pronosticos.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, $"Proceso ETL completado exitosamente. Se procesaron {pronosticosPorEstacion.Count} estaciones.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogCritical(ex, "Falló el proceso ETL para {SourceName}.", SourceName);
|
||||||
|
return (false, $"Error crítico: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<byte[]> DownloadZipFileAsync()
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("SmnApiClient");
|
||||||
|
return await client.GetByteArrayAsync(DataUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, List<Pronostico>> ParseTxtContent(byte[] zipBytes)
|
||||||
|
{
|
||||||
|
var pronosticosAgrupados = new Dictionary<string, List<Pronostico>>();
|
||||||
|
|
||||||
|
using var memoryStream = new MemoryStream(zipBytes);
|
||||||
|
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||||
|
|
||||||
|
// Buscamos el primer archivo .txt dentro del ZIP
|
||||||
|
var txtEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".txt", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (txtEntry == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No se encontró un archivo .txt dentro del ZIP descargado.");
|
||||||
|
return pronosticosAgrupados;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = txtEntry.Open();
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
|
||||||
|
string? currentStation = null;
|
||||||
|
List<Pronostico> currentForecasts = new List<Pronostico>();
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = reader.ReadLine()) != null)
|
||||||
|
{
|
||||||
|
// Si la línea es la de separación, y estábamos procesando una estación, la guardamos.
|
||||||
|
if (line.Trim().StartsWith("===="))
|
||||||
|
{
|
||||||
|
if (currentStation != null && currentForecasts.Any())
|
||||||
|
{
|
||||||
|
pronosticosAgrupados[currentStation] = new List<Pronostico>(currentForecasts);
|
||||||
|
_logger.LogDebug("Estación '{Station}' parseada con {Count} registros.", currentStation, currentForecasts.Count);
|
||||||
|
}
|
||||||
|
currentStation = null;
|
||||||
|
currentForecasts.Clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no estamos en una estación, buscamos la siguiente
|
||||||
|
if (currentStation == null)
|
||||||
|
{
|
||||||
|
// Una línea de nombre de estación no empieza con espacio
|
||||||
|
if (!string.IsNullOrWhiteSpace(line) && char.IsLetter(line[0]))
|
||||||
|
{
|
||||||
|
currentStation = line.Trim();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si estamos dentro de una estación, intentamos parsear la línea de datos
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Saltamos líneas vacías o de cabecera
|
||||||
|
if (string.IsNullOrWhiteSpace(line) || !char.IsDigit(line.Trim()[0])) continue;
|
||||||
|
|
||||||
|
var pronostico = ParseForecastLine(currentStation, line);
|
||||||
|
currentForecasts.Add(pronostico);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Se omitió una línea de pronóstico inválida para la estación '{Station}': '{Line}'", currentStation, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pronosticosAgrupados;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Pronostico ParseForecastLine(string station, string line)
|
||||||
|
{
|
||||||
|
// Parseo de fecha y hora. Ej: "25/JUL/2025 00Hs."
|
||||||
|
var fullDateString = line.Substring(2, 18).Trim();
|
||||||
|
var datePart = fullDateString.Substring(0, 11);
|
||||||
|
var hourPart = fullDateString.Substring(12, 2);
|
||||||
|
// Creamos un string de fecha compatible: "25/JUL/2025 00"
|
||||||
|
var parsableDateString = $"{datePart} {hourPart}";
|
||||||
|
// Usamos CultureInfo para que entienda "JUL" en español
|
||||||
|
var culture = new CultureInfo("es-AR");
|
||||||
|
var fechaHora = DateTime.ParseExact(parsableDateString, "dd/MMM/yyyy HH", culture);
|
||||||
|
|
||||||
|
// Parseo de valores numéricos de ancho fijo
|
||||||
|
var temperatura = decimal.Parse(line.Substring(28, 10).Trim(), CultureInfo.InvariantCulture);
|
||||||
|
var vientoData = line.Substring(41, 12).Split('|', StringSplitOptions.TrimEntries);
|
||||||
|
var vientoDir = int.Parse(vientoData[0]);
|
||||||
|
var vientoVel = int.Parse(vientoData[1]);
|
||||||
|
var precipitacion = decimal.Parse(line.Substring(59, 10).Trim(), CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
return new Pronostico
|
||||||
|
{
|
||||||
|
Estacion = station,
|
||||||
|
FechaHora = fechaHora,
|
||||||
|
TemperaturaC = temperatura,
|
||||||
|
VientoDirGrados = vientoDir,
|
||||||
|
VientoKmh = vientoVel,
|
||||||
|
PrecipitacionMm = precipitacion
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Clima.Infrastructure/Persistence/IDbConnectionFactory.cs
Normal file
15
src/Clima.Infrastructure/Persistence/IDbConnectionFactory.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Data;
|
||||||
|
|
||||||
|
namespace Clima.Infrastructure.Persistence
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Define una fábrica para crear conexiones a la base de datos.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDbConnectionFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Crea y devuelve una nueva instancia de IDbConnection.
|
||||||
|
/// </summary>
|
||||||
|
IDbConnection CreateConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ using Clima.Core.Entities;
|
|||||||
|
|
||||||
namespace Clima.Infrastructure.Persistence.Repositories
|
namespace Clima.Infrastructure.Persistence.Repositories
|
||||||
{
|
{
|
||||||
public interface IPronosticoRepository
|
public interface IPronosticoRepository
|
||||||
{
|
{
|
||||||
Task<IEnumerable<Pronostico>> ObtenerPorEstacionAsync(string nombreEstacion);
|
Task<IEnumerable<Pronostico>> ObtenerPorEstacionAsync(string nombreEstacion);
|
||||||
Task ReemplazarPronosticosPorEstacionAsync(string nombreEstacion, IEnumerable<Pronostico> pronosticos);
|
Task ReemplazarPronosticosPorEstacionAsync(string nombreEstacion, IEnumerable<Pronostico> pronosticos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,44 +5,44 @@ using System.Data;
|
|||||||
|
|
||||||
namespace Clima.Infrastructure.Persistence.Repositories
|
namespace Clima.Infrastructure.Persistence.Repositories
|
||||||
{
|
{
|
||||||
public class PronosticoRepository : IPronosticoRepository
|
public class PronosticoRepository : IPronosticoRepository
|
||||||
|
{
|
||||||
|
private readonly IDbConnectionFactory _dbConnectionFactory;
|
||||||
|
|
||||||
|
public PronosticoRepository(IDbConnectionFactory dbConnectionFactory)
|
||||||
{
|
{
|
||||||
private readonly IDbConnectionFactory _dbConnectionFactory;
|
_dbConnectionFactory = dbConnectionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
public PronosticoRepository(IDbConnectionFactory dbConnectionFactory)
|
public async Task<IEnumerable<Pronostico>> ObtenerPorEstacionAsync(string nombreEstacion)
|
||||||
{
|
{
|
||||||
_dbConnectionFactory = dbConnectionFactory;
|
using var connection = _dbConnectionFactory.CreateConnection();
|
||||||
}
|
const string sql = "SELECT * FROM Pronosticos WHERE Estacion = @NombreEstacion ORDER BY FechaHora ASC;";
|
||||||
|
return await connection.QueryAsync<Pronostico>(sql, new { NombreEstacion = nombreEstacion });
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<Pronostico>> ObtenerPorEstacionAsync(string nombreEstacion)
|
public async Task ReemplazarPronosticosPorEstacionAsync(string nombreEstacion, IEnumerable<Pronostico> pronosticos)
|
||||||
{
|
{
|
||||||
using var connection = _dbConnectionFactory.CreateConnection();
|
using var connection = _dbConnectionFactory.CreateConnection();
|
||||||
const string sql = "SELECT * FROM Pronosticos WHERE Estacion = @NombreEstacion ORDER BY FechaHora ASC;";
|
connection.Open();
|
||||||
return await connection.QueryAsync<Pronostico>(sql, new { NombreEstacion = nombreEstacion });
|
using var transaction = connection.BeginTransaction();
|
||||||
}
|
try
|
||||||
|
{
|
||||||
|
const string deleteSql = "DELETE FROM Pronosticos WHERE Estacion = @NombreEstacion;";
|
||||||
|
await connection.ExecuteAsync(deleteSql, new { NombreEstacion = nombreEstacion }, transaction);
|
||||||
|
|
||||||
public async Task ReemplazarPronosticosPorEstacionAsync(string nombreEstacion, IEnumerable<Pronostico> pronosticos)
|
const string insertSql = @"
|
||||||
{
|
|
||||||
using var connection = _dbConnectionFactory.CreateConnection();
|
|
||||||
connection.Open();
|
|
||||||
using var transaction = connection.BeginTransaction();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const string deleteSql = "DELETE FROM Pronosticos WHERE Estacion = @NombreEstacion;";
|
|
||||||
await connection.ExecuteAsync(deleteSql, new { NombreEstacion = nombreEstacion }, transaction);
|
|
||||||
|
|
||||||
const string insertSql = @"
|
|
||||||
INSERT INTO Pronosticos (Estacion, FechaHora, TemperaturaC, VientoDirGrados, VientoKmh, PrecipitacionMm)
|
INSERT INTO Pronosticos (Estacion, FechaHora, TemperaturaC, VientoDirGrados, VientoKmh, PrecipitacionMm)
|
||||||
VALUES (@Estacion, @FechaHora, @TemperaturaC, @VientoDirGrados, @VientoKmh, @PrecipitacionMm);";
|
VALUES (@Estacion, @FechaHora, @TemperaturaC, @VientoDirGrados, @VientoKmh, @PrecipitacionMm);";
|
||||||
await connection.ExecuteAsync(insertSql, pronosticos, transaction);
|
await connection.ExecuteAsync(insertSql, pronosticos, transaction);
|
||||||
|
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
transaction.Rollback();
|
transaction.Rollback();
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
28
src/Clima.Infrastructure/Persistence/SqlConnectionFactory.cs
Normal file
28
src/Clima.Infrastructure/Persistence/SqlConnectionFactory.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using System.Data;
|
||||||
|
|
||||||
|
namespace Clima.Infrastructure.Persistence
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Implementación de IDbConnectionFactory para Microsoft SQL Server.
|
||||||
|
/// </summary>
|
||||||
|
public class SqlConnectionFactory : IDbConnectionFactory
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
public SqlConnectionFactory(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
// Lee la cadena de conexión desde la configuración (appsettings.json, user secrets, etc.)
|
||||||
|
_connectionString = configuration.GetConnectionString("DefaultConnection")
|
||||||
|
?? throw new InvalidOperationException("La cadena de conexión 'DefaultConnection' no fue encontrada en la configuración.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDbConnection CreateConnection()
|
||||||
|
{
|
||||||
|
// Crea una nueva conexión de SQL Server.
|
||||||
|
// Dapper se encargará de abrirla y cerrarla automáticamente.
|
||||||
|
return new SqlConnection(_connectionString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,43 @@
|
|||||||
|
using Clima.Infrastructure.DataFetchers;
|
||||||
|
using Clima.Infrastructure.Persistence;
|
||||||
|
using Clima.Infrastructure.Persistence.Repositories;
|
||||||
using Clima.Worker;
|
using Clima.Worker;
|
||||||
|
using Polly;
|
||||||
|
using Polly.Extensions.Http;
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
IHost host = Host.CreateDefaultBuilder(args)
|
||||||
builder.Services.AddHostedService<Worker>();
|
.ConfigureServices((hostContext, services) =>
|
||||||
|
{
|
||||||
|
IConfiguration configuration = hostContext.Configuration;
|
||||||
|
|
||||||
var host = builder.Build();
|
// --- Registro de Servicios ---
|
||||||
host.Run();
|
|
||||||
|
// 1. Infraestructura de Persistencia
|
||||||
|
services.AddSingleton<IDbConnectionFactory, SqlConnectionFactory>();
|
||||||
|
services.AddScoped<IPronosticoRepository, PronosticoRepository>();
|
||||||
|
|
||||||
|
// 2. Fetcher de Datos
|
||||||
|
services.AddScoped<IDataFetcher, SmnEtlFetcher>();
|
||||||
|
|
||||||
|
// 3. Cliente HTTP con política de reintentos
|
||||||
|
services.AddHttpClient("SmnApiClient")
|
||||||
|
.AddPolicyHandler(GetRetryPolicy());
|
||||||
|
|
||||||
|
// 4. Servicio de Fondo principal
|
||||||
|
services.AddHostedService<SmnSyncService>();
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Define una política de reintentos para las llamadas HTTP.
|
||||||
|
/// Si una petición falla por un error transitorio, se reintentará hasta 3 veces
|
||||||
|
/// con una espera exponencial entre cada intento.
|
||||||
|
/// </summary>
|
||||||
|
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
|
||||||
|
{
|
||||||
|
return HttpPolicyExtensions
|
||||||
|
.HandleTransientHttpError() // Maneja errores de red comunes
|
||||||
|
.WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
|
||||||
|
}
|
||||||
|
|
||||||
|
await host.RunAsync();
|
||||||
53
src/Clima.Worker/SmnSyncService.cs
Normal file
53
src/Clima.Worker/SmnSyncService.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using Clima.Infrastructure.DataFetchers;
|
||||||
|
|
||||||
|
namespace Clima.Worker
|
||||||
|
{
|
||||||
|
public class SmnSyncService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<SmnSyncService> _logger;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
|
||||||
|
public SmnSyncService(ILogger<SmnSyncService> logger, IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Servicio de Sincronización del SMN iniciado.");
|
||||||
|
|
||||||
|
// Ejecutamos una vez al inicio
|
||||||
|
await RunSync(stoppingToken);
|
||||||
|
|
||||||
|
// Luego, se ejecuta cada 3 horas.
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromHours(3));
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
|
||||||
|
{
|
||||||
|
await RunSync(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunSync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
_logger.LogInformation("Iniciando ciclo de sincronización de datos del SMN a las: {time}", DateTimeOffset.Now);
|
||||||
|
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var fetcher = scope.ServiceProvider.GetRequiredService<IDataFetcher>();
|
||||||
|
|
||||||
|
var (success, message) = await fetcher.FetchDataAsync();
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Ciclo de sincronización completado: {message}", message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("Ciclo de sincronización falló: {message}", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,8 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.Hosting.Lifetime": "Information"
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user