feat: Worker Service - API endpoints
Implement and configure Worker Service to orchestrate data fetchers - Implement API endpoints for stock market data
This commit is contained in:
		
							
								
								
									
										57
									
								
								src/Mercados.Api/Controllers/MercadosController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/Mercados.Api/Controllers/MercadosController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | |||||||
|  | using Mercados.Core.Entities; | ||||||
|  | using Mercados.Infrastructure.Persistence.Repositories; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  |  | ||||||
|  | namespace Mercados.Api.Controllers | ||||||
|  | { | ||||||
|  |     [ApiController] | ||||||
|  |     [Route("api/[controller]")] | ||||||
|  |     public class MercadosController : ControllerBase | ||||||
|  |     { | ||||||
|  |         private readonly ICotizacionBolsaRepository _bolsaRepo; | ||||||
|  |         private readonly ILogger<MercadosController> _logger; | ||||||
|  |          | ||||||
|  |         // Inyectamos los repositorios que este controlador necesita. | ||||||
|  |         public MercadosController(ICotizacionBolsaRepository bolsaRepo, ILogger<MercadosController> logger) | ||||||
|  |         { | ||||||
|  |             _bolsaRepo = bolsaRepo; | ||||||
|  |             _logger = logger; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         [HttpGet("bolsa/eeuu")] | ||||||
|  |         [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
|  |         public async Task<IActionResult> GetBolsaUsa() | ||||||
|  |         { | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var data = await _bolsaRepo.ObtenerUltimasPorMercadoAsync("EEUU"); | ||||||
|  |                 return Ok(data); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al obtener cotizaciones de bolsa de EEUU."); | ||||||
|  |                 return StatusCode(500, "Ocurrió un error interno en el servidor."); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         [HttpGet("bolsa/local")] | ||||||
|  |         [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
|  |         public async Task<IActionResult> GetBolsaLocal() | ||||||
|  |         { | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var data = await _bolsaRepo.ObtenerUltimasPorMercadoAsync("Local"); | ||||||
|  |                 return Ok(data); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al obtener cotizaciones de bolsa local."); | ||||||
|  |                 return StatusCode(500, "Ocurrió un error interno en el servidor."); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // NOTA: Añadiremos los endpoints para Granos y Ganado en un momento. | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,17 +1,22 @@ | |||||||
| using FluentMigrator.Runner; // <--- AÑADIR | using FluentMigrator.Runner; | ||||||
| using Mercados.Database.Migrations; // <--- AÑADIR | using Mercados.Database.Migrations; | ||||||
| using Mercados.Infrastructure; | using Mercados.Infrastructure; | ||||||
| using Mercados.Infrastructure.Persistence; | using Mercados.Infrastructure.Persistence; | ||||||
| using System.Reflection; // <--- AÑADIR | using Mercados.Infrastructure.Persistence.Repositories; | ||||||
|  | using System.Reflection; | ||||||
|  |  | ||||||
| var builder = WebApplication.CreateBuilder(args); | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
| // --- V INICIO DE NUESTRO CÓDIGO V --- | // 1. Registramos nuestra fábrica de conexiones a la BD. | ||||||
|  |  | ||||||
| // 1. Registramos nuestra fábrica de conexiones. |  | ||||||
| builder.Services.AddSingleton<IDbConnectionFactory, SqlConnectionFactory>(); | builder.Services.AddSingleton<IDbConnectionFactory, SqlConnectionFactory>(); | ||||||
|  |  | ||||||
| // 2. Configurar FluentMigrator | // 2. AÑADIR: Registramos los repositorios que la API necesitará para LEER datos. | ||||||
|  | builder.Services.AddScoped<ICotizacionGanadoRepository, CotizacionGanadoRepository>(); | ||||||
|  | builder.Services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); | ||||||
|  | builder.Services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); | ||||||
|  | builder.Services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>(); | ||||||
|  |  | ||||||
|  | // 3. Configurar FluentMigrator | ||||||
| builder.Services | builder.Services | ||||||
|     .AddFluentMigratorCore() |     .AddFluentMigratorCore() | ||||||
|     .ConfigureRunner(rb => rb |     .ConfigureRunner(rb => rb | ||||||
| @@ -25,8 +30,6 @@ builder.Services | |||||||
|     .AddLogging(lb => lb.AddFluentMigratorConsole()); |     .AddLogging(lb => lb.AddFluentMigratorConsole()); | ||||||
|  |  | ||||||
|  |  | ||||||
| // --- ^ FIN DE NUESTRO CÓDIGO ^ --- |  | ||||||
|  |  | ||||||
| // Add services to the container. | // Add services to the container. | ||||||
| builder.Services.AddControllers(); | builder.Services.AddControllers(); | ||||||
| // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle | // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle | ||||||
| @@ -35,9 +38,7 @@ builder.Services.AddSwaggerGen(); | |||||||
|  |  | ||||||
| var app = builder.Build(); | var app = builder.Build(); | ||||||
|  |  | ||||||
| // --- V INICIO DE NUESTRO CÓDIGO DE EJECUCIÓN V --- | // 4. Ejecutar las migraciones al iniciar la aplicación (ideal para desarrollo y despliegues sencillos) | ||||||
|  |  | ||||||
| // 3. Ejecutar las migraciones al iniciar la aplicación (ideal para desarrollo y despliegues sencillos) |  | ||||||
| // Obtenemos el "scope" de los servicios para poder solicitar el MigrationRunner | // Obtenemos el "scope" de los servicios para poder solicitar el MigrationRunner | ||||||
| using (var scope = app.Services.CreateScope()) | using (var scope = app.Services.CreateScope()) | ||||||
| { | { | ||||||
| @@ -46,9 +47,6 @@ using (var scope = app.Services.CreateScope()) | |||||||
|     migrationRunner.MigrateUp(); |     migrationRunner.MigrateUp(); | ||||||
| } | } | ||||||
|  |  | ||||||
| // --- ^ FIN DE NUESTRO CÓDIGO DE EJECUCIÓN ^ --- |  | ||||||
|  |  | ||||||
|  |  | ||||||
| // Configure the HTTP request pipeline. | // Configure the HTTP request pipeline. | ||||||
| if (app.Environment.IsDevelopment()) | if (app.Environment.IsDevelopment()) | ||||||
| { | { | ||||||
|   | |||||||
| @@ -8,5 +8,12 @@ | |||||||
|   "AllowedHosts": "*", |   "AllowedHosts": "*", | ||||||
|   "ConnectionStrings": { |   "ConnectionStrings": { | ||||||
|     "DefaultConnection": "Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;" |     "DefaultConnection": "Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;" | ||||||
|  |   }, | ||||||
|  |   "ApiKeys": { | ||||||
|  |     "Finnhub": "cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30", | ||||||
|  |     "Bcr": { | ||||||
|  |       "Key": "D1782A51-A5FD-EF11-9445-00155D09E201", | ||||||
|  |       "Secret": "da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3" | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
							
								
								
									
										138
									
								
								src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,138 @@ | |||||||
|  | using Mercados.Core.Entities; | ||||||
|  | using Mercados.Infrastructure.Persistence.Repositories; | ||||||
|  | using Microsoft.Extensions.Configuration; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using System.Net.Http.Headers; | ||||||
|  | using System.Net.Http.Json; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
|  |  | ||||||
|  | namespace Mercados.Infrastructure.DataFetchers | ||||||
|  | { | ||||||
|  |     public class BcrDataFetcher : IDataFetcher | ||||||
|  |     { | ||||||
|  |         #region Clases DTO para la respuesta de la API de BCR | ||||||
|  |         private class BcrTokenResponse { | ||||||
|  |             [JsonPropertyName("data")] | ||||||
|  |             public TokenData? Data { get; set; } | ||||||
|  |         } | ||||||
|  |         private class TokenData { | ||||||
|  |             [JsonPropertyName("token")] | ||||||
|  |             public string? Token { get; set; } | ||||||
|  |         } | ||||||
|  |         private class BcrPreciosResponse { | ||||||
|  |             [JsonPropertyName("data")] | ||||||
|  |             public List<BcrPrecioItem>? Data { get; set; } | ||||||
|  |         } | ||||||
|  |         private class BcrPrecioItem { | ||||||
|  |             [JsonPropertyName("precio_Cotizacion")] | ||||||
|  |             public decimal PrecioCotizacion { get; set; } | ||||||
|  |             [JsonPropertyName("variacion_Precio_Cotizacion")] | ||||||
|  |             public decimal VariacionPrecioCotizacion { get; set; } | ||||||
|  |             [JsonPropertyName("fecha_Operacion_Pizarra")] | ||||||
|  |             public DateTime FechaOperacionPizarra { get; set; } | ||||||
|  |         } | ||||||
|  |         #endregion | ||||||
|  |  | ||||||
|  |         public string SourceName => "BCR"; | ||||||
|  |         private const string BaseUrl = "https://api.bcr.com.ar/gix/v1.0"; | ||||||
|  |         private readonly Dictionary<string, int> _grainIds = new() | ||||||
|  |         { | ||||||
|  |             { "Trigo", 1 }, { "Maiz", 2 }, { "Sorgo", 3 }, { "Girasol", 20 }, { "Soja", 21 } | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         private readonly IHttpClientFactory _httpClientFactory; | ||||||
|  |         private readonly ICotizacionGranoRepository _cotizacionRepository; | ||||||
|  |         private readonly IFuenteDatoRepository _fuenteDatoRepository; | ||||||
|  |         private readonly IConfiguration _configuration; | ||||||
|  |         private readonly ILogger<BcrDataFetcher> _logger; | ||||||
|  |  | ||||||
|  |         public BcrDataFetcher( | ||||||
|  |             IHttpClientFactory httpClientFactory,  | ||||||
|  |             ICotizacionGranoRepository cotizacionRepository,  | ||||||
|  |             IFuenteDatoRepository fuenteDatoRepository,  | ||||||
|  |             IConfiguration configuration,  | ||||||
|  |             ILogger<BcrDataFetcher> logger) | ||||||
|  |         { | ||||||
|  |             _httpClientFactory = httpClientFactory; | ||||||
|  |             _cotizacionRepository = cotizacionRepository; | ||||||
|  |             _fuenteDatoRepository = fuenteDatoRepository; | ||||||
|  |             _configuration = configuration; | ||||||
|  |             _logger = logger; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||||
|  |         { | ||||||
|  |             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var client = _httpClientFactory.CreateClient(); | ||||||
|  |                 var token = await GetAuthTokenAsync(client); | ||||||
|  |                 if (string.IsNullOrEmpty(token)) | ||||||
|  |                 { | ||||||
|  |                     return (false, "No se pudo obtener el token de autenticación de BCR."); | ||||||
|  |                 } | ||||||
|  |                 client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); | ||||||
|  |  | ||||||
|  |                 var cotizaciones = new List<CotizacionGrano>(); | ||||||
|  |                 foreach (var grain in _grainIds) | ||||||
|  |                 { | ||||||
|  |                     var response = await client.GetFromJsonAsync<BcrPreciosResponse>( | ||||||
|  |                         $"{BaseUrl}/PreciosCamara?idGrano={grain.Value}&fechaConcertacionDesde={DateTime.Now.AddDays(-3):yyyy-MM-dd}&fechaConcertacionHasta={DateTime.Now:yyyy-MM-dd}"); | ||||||
|  |  | ||||||
|  |                     var latestRecord = response?.Data?.OrderByDescending(r => r.FechaOperacionPizarra).FirstOrDefault(); | ||||||
|  |                     if (latestRecord != null) | ||||||
|  |                     { | ||||||
|  |                         cotizaciones.Add(new CotizacionGrano | ||||||
|  |                         { | ||||||
|  |                             Nombre = grain.Key, | ||||||
|  |                             Precio = latestRecord.PrecioCotizacion, | ||||||
|  |                             VariacionPrecio = latestRecord.VariacionPrecioCotizacion, | ||||||
|  |                             FechaOperacion = latestRecord.FechaOperacionPizarra, | ||||||
|  |                             FechaRegistro = DateTime.UtcNow | ||||||
|  |                         }); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 if (!cotizaciones.Any()) return (false, "No se obtuvieron datos de granos de BCR."); | ||||||
|  |  | ||||||
|  |                 await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | ||||||
|  |                 await UpdateSourceInfoAsync(); | ||||||
|  |                  | ||||||
|  |                 _logger.LogInformation("Fetch para {SourceName} completado. Se guardaron {Count} registros.", SourceName, cotizaciones.Count); | ||||||
|  |                 return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros."); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName); | ||||||
|  |                 return (false, $"Error: {ex.Message}"); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private async Task<string?> GetAuthTokenAsync(HttpClient client) | ||||||
|  |         { | ||||||
|  |             var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/Login"); | ||||||
|  |             request.Headers.Add("api_key", _configuration["ApiKeys:Bcr:Key"]); | ||||||
|  |             request.Headers.Add("secret", _configuration["ApiKeys:Bcr:Secret"]); | ||||||
|  |              | ||||||
|  |             var response = await client.SendAsync(request); | ||||||
|  |             response.EnsureSuccessStatusCode(); | ||||||
|  |  | ||||||
|  |             var tokenResponse = await response.Content.ReadFromJsonAsync<BcrTokenResponse>(); | ||||||
|  |             return tokenResponse?.Data?.Token; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         private async Task UpdateSourceInfoAsync() | ||||||
|  |         { | ||||||
|  |             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||||
|  |             if (fuente == null) | ||||||
|  |             { | ||||||
|  |                 await _fuenteDatoRepository.CrearAsync(new FuenteDato { Nombre = SourceName, Url = BaseUrl, UltimaEjecucionExitosa = DateTime.UtcNow }); | ||||||
|  |             } | ||||||
|  |             else | ||||||
|  |             { | ||||||
|  |                 fuente.UltimaEjecucionExitosa = DateTime.UtcNow; | ||||||
|  |                 await _fuenteDatoRepository.ActualizarAsync(fuente); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,96 @@ | |||||||
|  | using ThreeFourteen.Finnhub.Client; | ||||||
|  | using Mercados.Core.Entities; | ||||||
|  | using Mercados.Infrastructure.Persistence.Repositories; | ||||||
|  | using Microsoft.Extensions.Configuration; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using System.Net.Http; | ||||||
|  |  | ||||||
|  | namespace Mercados.Infrastructure.DataFetchers | ||||||
|  | { | ||||||
|  |     public class FinnhubDataFetcher : IDataFetcher | ||||||
|  |     { | ||||||
|  |         public string SourceName => "Finnhub"; | ||||||
|  |         private readonly List<string> _tickers = new() { | ||||||
|  |             "AAPL", "AMD", "AMZN", "BRK-B", "KO", "MSFT", "NVDA", "GLD",  | ||||||
|  |             "XLF", "XLI", "XLE", "XLK", "YPF", "GGAL", "BMA", "TEO", | ||||||
|  |             "PAM", "CEPU", "LOMA", "CRESY", "BBAR", "TGS", "EDN", "MELI", "GLOB" | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         private readonly FinnhubClient _client; | ||||||
|  |         private readonly ICotizacionBolsaRepository _cotizacionRepository; | ||||||
|  |         private readonly IFuenteDatoRepository _fuenteDatoRepository; | ||||||
|  |         private readonly ILogger<FinnhubDataFetcher> _logger; | ||||||
|  |  | ||||||
|  |         public FinnhubDataFetcher( | ||||||
|  |             IConfiguration configuration, | ||||||
|  |             IHttpClientFactory httpClientFactory, | ||||||
|  |             ICotizacionBolsaRepository cotizacionRepository, | ||||||
|  |             IFuenteDatoRepository fuenteDatoRepository, | ||||||
|  |             ILogger<FinnhubDataFetcher> logger) | ||||||
|  |         { | ||||||
|  |             var apiKey = configuration["ApiKeys:Finnhub"]; | ||||||
|  |             if (string.IsNullOrEmpty(apiKey)) | ||||||
|  |             { | ||||||
|  |                 throw new InvalidOperationException("La clave de API de Finnhub no está configurada en appsettings.json (ApiKeys:Finnhub)"); | ||||||
|  |             } | ||||||
|  |             _client = new FinnhubClient(httpClientFactory.CreateClient("Finnhub"), apiKey); | ||||||
|  |             _cotizacionRepository = cotizacionRepository; | ||||||
|  |             _fuenteDatoRepository = fuenteDatoRepository; | ||||||
|  |             _logger = logger; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||||
|  |         { | ||||||
|  |             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||||
|  |             var cotizaciones = new List<CotizacionBolsa>(); | ||||||
|  |              | ||||||
|  |             foreach (var ticker in _tickers) | ||||||
|  |             { | ||||||
|  |                 try | ||||||
|  |                 { | ||||||
|  |                     var quote = await _client.Stock.GetQuote(ticker); | ||||||
|  |  | ||||||
|  |                     if (quote.Current == 0 || quote.PreviousClose == 0) continue; | ||||||
|  |  | ||||||
|  |                     var pctChange = ((quote.Current - quote.PreviousClose) / quote.PreviousClose) * 100; | ||||||
|  |                     cotizaciones.Add(new CotizacionBolsa | ||||||
|  |                     { | ||||||
|  |                         Ticker = ticker, | ||||||
|  |                         Mercado = "EEUU", | ||||||
|  |                         PrecioActual = (decimal)quote.Current, | ||||||
|  |                         Apertura = (decimal)quote.Open, | ||||||
|  |                         CierreAnterior = (decimal)quote.PreviousClose, | ||||||
|  |                         PorcentajeCambio = (decimal)Math.Round(pctChange, 4), | ||||||
|  |                         FechaRegistro = DateTime.UtcNow | ||||||
|  |                     }); | ||||||
|  |                 } | ||||||
|  |                 catch (Exception ex) | ||||||
|  |                 { | ||||||
|  |                     _logger.LogWarning(ex, "No se pudo obtener la cotización para el ticker {Ticker} de Finnhub.", ticker); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!cotizaciones.Any()) return (false, "No se obtuvieron datos de Finnhub."); | ||||||
|  |  | ||||||
|  |             await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | ||||||
|  |             await UpdateSourceInfoAsync(); | ||||||
|  |              | ||||||
|  |             _logger.LogInformation("Fetch para {SourceName} completado. Se guardaron {Count} registros.", SourceName, cotizaciones.Count); | ||||||
|  |             return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros."); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         private async Task UpdateSourceInfoAsync() | ||||||
|  |         { | ||||||
|  |             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||||
|  |             if (fuente == null) | ||||||
|  |             { | ||||||
|  |                 await _fuenteDatoRepository.CrearAsync(new FuenteDato { Nombre = SourceName, Url = "https://finnhub.io/", UltimaEjecucionExitosa = DateTime.UtcNow }); | ||||||
|  |             } | ||||||
|  |             else | ||||||
|  |             { | ||||||
|  |                 fuente.UltimaEjecucionExitosa = DateTime.UtcNow; | ||||||
|  |                 await _fuenteDatoRepository.ActualizarAsync(fuente); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,85 @@ | |||||||
|  | using Mercados.Core.Entities; | ||||||
|  | using Mercados.Infrastructure.Persistence.Repositories; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using YahooFinanceApi; | ||||||
|  |  | ||||||
|  | namespace Mercados.Infrastructure.DataFetchers | ||||||
|  | { | ||||||
|  |     public class YahooFinanceDataFetcher : IDataFetcher | ||||||
|  |     { | ||||||
|  |         public string SourceName => "YahooFinance"; | ||||||
|  |         private readonly List<string> _tickers = new() { | ||||||
|  |             "^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.BA",  | ||||||
|  |             "TECO2.BA", "EDN.BA", "CRES.BA", "TXAR.BA", "MIRG.BA",  | ||||||
|  |             "CEPU.BA", "LOMA.BA", "VALO.BA" | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         private readonly ICotizacionBolsaRepository _cotizacionRepository; | ||||||
|  |         private readonly IFuenteDatoRepository _fuenteDatoRepository; | ||||||
|  |         private readonly ILogger<YahooFinanceDataFetcher> _logger; | ||||||
|  |  | ||||||
|  |         public YahooFinanceDataFetcher( | ||||||
|  |             ICotizacionBolsaRepository cotizacionRepository,  | ||||||
|  |             IFuenteDatoRepository fuenteDatoRepository,  | ||||||
|  |             ILogger<YahooFinanceDataFetcher> logger) | ||||||
|  |         { | ||||||
|  |             _cotizacionRepository = cotizacionRepository; | ||||||
|  |             _fuenteDatoRepository = fuenteDatoRepository; | ||||||
|  |             _logger = logger; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||||
|  |         { | ||||||
|  |             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 // La librería puede obtener múltiples tickers en una sola llamada. | ||||||
|  |                 var securities = await Yahoo.Symbols(_tickers.ToArray()).Fields(Field.RegularMarketPrice, Field.RegularMarketOpen, Field.RegularMarketPreviousClose, Field.RegularMarketChangePercent).QueryAsync(); | ||||||
|  |                 var cotizaciones = new List<CotizacionBolsa>(); | ||||||
|  |  | ||||||
|  |                 foreach (var sec in securities.Values) | ||||||
|  |                 { | ||||||
|  |                     if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue; | ||||||
|  |                      | ||||||
|  |                     cotizaciones.Add(new CotizacionBolsa | ||||||
|  |                     { | ||||||
|  |                         Ticker = sec.Symbol, | ||||||
|  |                         Mercado = "Local", | ||||||
|  |                         PrecioActual = (decimal)sec.RegularMarketPrice, | ||||||
|  |                         Apertura = (decimal)sec.RegularMarketOpen, | ||||||
|  |                         CierreAnterior = (decimal)sec.RegularMarketPreviousClose, | ||||||
|  |                         PorcentajeCambio = (decimal)sec.RegularMarketChangePercent, | ||||||
|  |                         FechaRegistro = DateTime.UtcNow | ||||||
|  |                     }); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (!cotizaciones.Any()) return (false, "No se obtuvieron datos de Yahoo Finance."); | ||||||
|  |  | ||||||
|  |                 await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | ||||||
|  |                 await UpdateSourceInfoAsync(); | ||||||
|  |  | ||||||
|  |                 _logger.LogInformation("Fetch para {SourceName} completado. Se guardaron {Count} registros.", SourceName, cotizaciones.Count); | ||||||
|  |                 return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros."); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName); | ||||||
|  |                 return (false, $"Error: {ex.Message}"); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         private async Task UpdateSourceInfoAsync() | ||||||
|  |         { | ||||||
|  |             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||||
|  |             if (fuente == null) | ||||||
|  |             { | ||||||
|  |                 await _fuenteDatoRepository.CrearAsync(new FuenteDato { Nombre = SourceName, Url = "https://finance.yahoo.com/", UltimaEjecucionExitosa = DateTime.UtcNow }); | ||||||
|  |             } | ||||||
|  |             else | ||||||
|  |             { | ||||||
|  |                 fuente.UltimaEjecucionExitosa = DateTime.UtcNow; | ||||||
|  |                 await _fuenteDatoRepository.ActualizarAsync(fuente); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -9,6 +9,8 @@ | |||||||
|     <PackageReference Include="Dapper" Version="2.1.66" /> |     <PackageReference Include="Dapper" Version="2.1.66" /> | ||||||
|     <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="ThreeFourteen.Finnhub.Client" Version="1.2.0" /> | ||||||
|  |     <PackageReference Include="YahooFinanceApi" Version="2.3.3" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|  |  | ||||||
|   <PropertyGroup> |   <PropertyGroup> | ||||||
|   | |||||||
| @@ -0,0 +1,54 @@ | |||||||
|  | using Dapper; | ||||||
|  | using Mercados.Core.Entities; | ||||||
|  | using System.Data; | ||||||
|  |  | ||||||
|  | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
|  | { | ||||||
|  |     public class CotizacionBolsaRepository : ICotizacionBolsaRepository | ||||||
|  |     { | ||||||
|  |         private readonly IDbConnectionFactory _connectionFactory; | ||||||
|  |  | ||||||
|  |         public CotizacionBolsaRepository(IDbConnectionFactory connectionFactory) | ||||||
|  |         { | ||||||
|  |             _connectionFactory = connectionFactory; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones) | ||||||
|  |         { | ||||||
|  |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|  |  | ||||||
|  |             const string sql = @" | ||||||
|  |                 INSERT INTO CotizacionesBolsa (Ticker, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro)  | ||||||
|  |                 VALUES (@Ticker, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);"; | ||||||
|  |  | ||||||
|  |             await connection.ExecuteAsync(sql, cotizaciones); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         public async Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado) | ||||||
|  |         { | ||||||
|  |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|  |  | ||||||
|  |             // Esta consulta SQL es un poco más avanzada. Usa una "Common Table Expression" (CTE) | ||||||
|  |             // y la función ROW_NUMBER() para obtener el registro más reciente para cada Ticker | ||||||
|  |             // dentro del mercado especificado. Es extremadamente eficiente. | ||||||
|  |             const string sql = @" | ||||||
|  |                 WITH RankedCotizaciones AS ( | ||||||
|  |                     SELECT  | ||||||
|  |                         *, | ||||||
|  |                         ROW_NUMBER() OVER(PARTITION BY Ticker ORDER BY FechaRegistro DESC) as rn | ||||||
|  |                     FROM  | ||||||
|  |                         CotizacionesBolsa | ||||||
|  |                     WHERE  | ||||||
|  |                         Mercado = @Mercado | ||||||
|  |                 ) | ||||||
|  |                 SELECT  | ||||||
|  |                     Id, Ticker, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro | ||||||
|  |                 FROM  | ||||||
|  |                     RankedCotizaciones | ||||||
|  |                 WHERE  | ||||||
|  |                     rn = 1;"; | ||||||
|  |  | ||||||
|  |             return await connection.QueryAsync<CotizacionBolsa>(sql, new { Mercado = mercado }); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,27 @@ | |||||||
|  | using Dapper; | ||||||
|  | using Mercados.Core.Entities; | ||||||
|  | using System.Data; | ||||||
|  |  | ||||||
|  | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
|  | { | ||||||
|  |     public class CotizacionGranoRepository : ICotizacionGranoRepository | ||||||
|  |     { | ||||||
|  |         private readonly IDbConnectionFactory _connectionFactory; | ||||||
|  |  | ||||||
|  |         public CotizacionGranoRepository(IDbConnectionFactory connectionFactory) | ||||||
|  |         { | ||||||
|  |             _connectionFactory = connectionFactory; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones) | ||||||
|  |         { | ||||||
|  |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|  |              | ||||||
|  |             const string sql = @" | ||||||
|  |                 INSERT INTO CotizacionesGranos (Nombre, Precio, VariacionPrecio, FechaOperacion, FechaRegistro)  | ||||||
|  |                 VALUES (@Nombre, @Precio, @VariacionPrecio, @FechaOperacion, @FechaRegistro);"; | ||||||
|  |  | ||||||
|  |             await connection.ExecuteAsync(sql, cotizaciones); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,10 @@ | |||||||
|  | using Mercados.Core.Entities; | ||||||
|  |  | ||||||
|  | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
|  | { | ||||||
|  |     public interface ICotizacionBolsaRepository : IBaseRepository | ||||||
|  |     { | ||||||
|  |         Task GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones); | ||||||
|  |         Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | using Mercados.Core.Entities; | ||||||
|  |  | ||||||
|  | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
|  | { | ||||||
|  |     public interface ICotizacionGranoRepository : IBaseRepository | ||||||
|  |     { | ||||||
|  |         Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										121
									
								
								src/Mercados.Worker/DataFetchingService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								src/Mercados.Worker/DataFetchingService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | |||||||
|  | using Mercados.Infrastructure.DataFetchers; | ||||||
|  |  | ||||||
|  | namespace Mercados.Worker | ||||||
|  | { | ||||||
|  |     public class DataFetchingService : BackgroundService | ||||||
|  |     { | ||||||
|  |         private readonly ILogger<DataFetchingService> _logger; | ||||||
|  |         private readonly IServiceProvider _serviceProvider; | ||||||
|  |  | ||||||
|  |         // Diccionario para rastrear la última vez que se ejecutó una tarea diaria. | ||||||
|  |         private readonly Dictionary<string, DateTime> _lastDailyRun = new(); | ||||||
|  |  | ||||||
|  |         public DataFetchingService(ILogger<DataFetchingService> logger, IServiceProvider serviceProvider) | ||||||
|  |         { | ||||||
|  |             _logger = logger; | ||||||
|  |             _serviceProvider = serviceProvider; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||||
|  |         { | ||||||
|  |             _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); | ||||||
|  |  | ||||||
|  |             // Ejecutamos una vez al inicio para tener datos frescos inmediatamente. | ||||||
|  |             await RunAllFetchersAsync(); | ||||||
|  |  | ||||||
|  |             // Usamos un PeriodicTimer que "despierta" cada minuto para revisar si hay tareas pendientes. | ||||||
|  |             using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); | ||||||
|  |  | ||||||
|  |             while (await timer.WaitForNextTickAsync(stoppingToken)) | ||||||
|  |             { | ||||||
|  |                 await RunScheduledTasksAsync(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private async Task RunScheduledTasksAsync() | ||||||
|  |         { | ||||||
|  |             // --- Lógica de Planificación --- | ||||||
|  |             var now = DateTime.Now; | ||||||
|  |  | ||||||
|  |             // Tarea 1: Mercado Agroganadero (todos los días a las 11:00) | ||||||
|  |             if (now.Hour == 11 && now.Minute == 0 && HasNotRunToday("MercadoAgroganadero")) | ||||||
|  |             { | ||||||
|  |                 await RunFetcherByNameAsync("MercadoAgroganadero"); | ||||||
|  |                 _lastDailyRun["MercadoAgroganadero"] = now.Date; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Tarea 2: Granos BCR (todos los días a las 11:30) | ||||||
|  |             if (now.Hour == 11 && now.Minute == 30 && HasNotRunToday("BCR")) | ||||||
|  |             { | ||||||
|  |                 await RunFetcherByNameAsync("BCR"); | ||||||
|  |                 _lastDailyRun["BCR"] = now.Date; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Tarea 3: Mercados de Bolsa (cada 10 minutos si el mercado está abierto) | ||||||
|  |             if (IsMarketOpen(now) && now.Minute % 10 == 0) | ||||||
|  |             { | ||||||
|  |                 _logger.LogInformation("Mercados abiertos. Ejecutando fetchers de bolsa."); | ||||||
|  |                 await RunFetcherByNameAsync("Finnhub"); | ||||||
|  |                 await RunFetcherByNameAsync("YahooFinance"); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Esta función crea un "scope" para ejecutar un fetcher específico. | ||||||
|  |         // Esto es crucial para que la inyección de dependencias funcione correctamente. | ||||||
|  |         private async Task RunFetcherByNameAsync(string sourceName) | ||||||
|  |         { | ||||||
|  |             _logger.LogInformation("Intentando ejecutar fetcher: {sourceName}", sourceName); | ||||||
|  |              | ||||||
|  |             using var scope = _serviceProvider.CreateScope(); | ||||||
|  |             var fetchers = scope.ServiceProvider.GetRequiredService<IEnumerable<IDataFetcher>>(); | ||||||
|  |             var fetcher = fetchers.FirstOrDefault(f => f.SourceName.Equals(sourceName, StringComparison.OrdinalIgnoreCase)); | ||||||
|  |  | ||||||
|  |             if (fetcher != null) | ||||||
|  |             { | ||||||
|  |                 var (success, message) = await fetcher.FetchDataAsync(); | ||||||
|  |                 if (!success) | ||||||
|  |                 { | ||||||
|  |                     _logger.LogError("Falló la ejecución del fetcher {sourceName}: {message}", sourceName, message); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             else | ||||||
|  |             { | ||||||
|  |                 _logger.LogWarning("No se encontró un fetcher con el nombre: {sourceName}", sourceName); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Función de ayuda para ejecutar todos los fetchers (usada al inicio). | ||||||
|  |         private async Task RunAllFetchersAsync() | ||||||
|  |         { | ||||||
|  |             _logger.LogInformation("Ejecutando todos los fetchers al iniciar..."); | ||||||
|  |             using var scope = _serviceProvider.CreateScope(); | ||||||
|  |             var fetchers = scope.ServiceProvider.GetRequiredService<IEnumerable<IDataFetcher>>(); | ||||||
|  |             foreach (var fetcher in fetchers) | ||||||
|  |             { | ||||||
|  |                 await RunFetcherByNameAsync(fetcher.SourceName); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         #region Funciones de Ayuda para la Planificación | ||||||
|  |  | ||||||
|  |         private bool HasNotRunToday(string taskName) | ||||||
|  |         { | ||||||
|  |             return !_lastDailyRun.ContainsKey(taskName) || _lastDailyRun[taskName].Date < DateTime.Now.Date; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private bool IsMarketOpen(DateTime now) | ||||||
|  |         { | ||||||
|  |             // Lunes a Viernes (1 a 5, Domingo es 0) | ||||||
|  |             if (now.DayOfWeek == DayOfWeek.Saturday || now.DayOfWeek == DayOfWeek.Sunday) | ||||||
|  |                 return false; | ||||||
|  |              | ||||||
|  |             // Horario de mercado de 11:00 a 17:15 (hora de Argentina) | ||||||
|  |             var marketOpen = new TimeSpan(11, 0, 0); | ||||||
|  |             var marketClose = new TimeSpan(17, 15, 0); | ||||||
|  |  | ||||||
|  |             return now.TimeOfDay >= marketOpen && now.TimeOfDay <= marketClose; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         #endregion | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,7 +1,51 @@ | |||||||
|  | using Mercados.Infrastructure; | ||||||
|  | using Mercados.Infrastructure.DataFetchers; | ||||||
|  | using Mercados.Infrastructure.Persistence; | ||||||
|  | using Mercados.Infrastructure.Persistence.Repositories; | ||||||
| using Mercados.Worker; | using Mercados.Worker; | ||||||
|  |  | ||||||
| var builder = Host.CreateApplicationBuilder(args); | // --- Configuración del Host --- | ||||||
| builder.Services.AddHostedService<Worker>(); | // Esto prepara el host del servicio, permitiendo la inyección de dependencias, | ||||||
|  | // la configuración desde appsettings.json y el logging. | ||||||
|  | IHost host = Host.CreateDefaultBuilder(args) | ||||||
|  |     .ConfigureServices((hostContext, services) => | ||||||
|  |     { | ||||||
|  |         // Obtenemos la configuración desde el host builder para usarla aquí. | ||||||
|  |         IConfiguration configuration = hostContext.Configuration; | ||||||
|  |  | ||||||
| var host = builder.Build(); |         // --- 1. Registro de Servicios de Infraestructura --- | ||||||
| host.Run(); |  | ||||||
|  |         // 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>(); | ||||||
|  |  | ||||||
|  |         // Registramos los repositorios. Se crean "por petición" (Scoped). | ||||||
|  |         // En un worker, "Scoped" significa que se creará una instancia por cada | ||||||
|  |         // ejecución del servicio, lo cual es seguro y eficiente. | ||||||
|  |         services.AddScoped<ICotizacionGanadoRepository, CotizacionGanadoRepository>(); | ||||||
|  |         services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); | ||||||
|  |         services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); | ||||||
|  |         services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>(); | ||||||
|  |  | ||||||
|  |         // --- 2. Registro de los Data Fetchers --- | ||||||
|  |  | ||||||
|  |         // Registramos CADA uno de nuestros fetchers. El contenedor de DI sabrá | ||||||
|  |         // que todos implementan la interfaz IDataFetcher. | ||||||
|  |         services.AddScoped<IDataFetcher, MercadoAgroFetcher>(); | ||||||
|  |         services.AddScoped<IDataFetcher, BcrDataFetcher>(); | ||||||
|  |         //services.AddScoped<IDataFetcher, FinnhubDataFetcher>(); | ||||||
|  |         services.AddScoped<IDataFetcher, YahooFinanceDataFetcher>(); | ||||||
|  |          | ||||||
|  |         // El cliente HTTP es fundamental para hacer llamadas a APIs externas. | ||||||
|  |         // Le damos un nombre al cliente de Finnhub para cumplir con los requisitos de su constructor. | ||||||
|  |         services.AddHttpClient("Finnhub"); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |         // --- 3. Registro del Worker Principal --- | ||||||
|  |          | ||||||
|  |         // Finalmente, registramos nuestro servicio de fondo (el worker en sí). | ||||||
|  |         services.AddHostedService<DataFetchingService>(); | ||||||
|  |     }) | ||||||
|  |     .Build(); | ||||||
|  |  | ||||||
|  | await host.RunAsync(); | ||||||
| @@ -1,23 +0,0 @@ | |||||||
| namespace Mercados.Worker; |  | ||||||
|  |  | ||||||
| public class Worker : BackgroundService |  | ||||||
| { |  | ||||||
|     private readonly ILogger<Worker> _logger; |  | ||||||
|  |  | ||||||
|     public Worker(ILogger<Worker> logger) |  | ||||||
|     { |  | ||||||
|         _logger = logger; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     protected override async Task ExecuteAsync(CancellationToken stoppingToken) |  | ||||||
|     { |  | ||||||
|         while (!stoppingToken.IsCancellationRequested) |  | ||||||
|         { |  | ||||||
|             if (_logger.IsEnabled(LogLevel.Information)) |  | ||||||
|             { |  | ||||||
|                 _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now); |  | ||||||
|             } |  | ||||||
|             await Task.Delay(1000, stoppingToken); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -2,7 +2,18 @@ | |||||||
|   "Logging": { |   "Logging": { | ||||||
|     "LogLevel": { |     "LogLevel": { | ||||||
|       "Default": "Information", |       "Default": "Information", | ||||||
|       "Microsoft.Hosting.Lifetime": "Information" |       "Microsoft.AspNetCore": "Warning" | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "AllowedHosts": "*", | ||||||
|  |   "ConnectionStrings": { | ||||||
|  |     "DefaultConnection": "Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;" | ||||||
|  |   }, | ||||||
|  |   "ApiKeys": { | ||||||
|  |     "Finnhub": "cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30", | ||||||
|  |     "Bcr": { | ||||||
|  |       "Key": "D1782A51-A5FD-EF11-9445-00155D09E201", | ||||||
|  |       "Secret": "da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3" | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
		Reference in New Issue
	
	Block a user