diff --git a/FinlyticCore/Clients/YahooFinanceClient.cs b/FinlyticCore/Clients/YahooFinanceClient.cs index 2aa8a8e..a1c4abc 100644 --- a/FinlyticCore/Clients/YahooFinanceClient.cs +++ b/FinlyticCore/Clients/YahooFinanceClient.cs @@ -7,109 +7,17 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos.Yahoo; -using Microsoft.Extensions.Logging; +using FinlyticCore.Models.Settings; +using FinlyticCore.Services; namespace FinlyticCore.Services.Yahoo; - -/// -/// Thread-sicherer Client für den Zugriff auf die internen Yahoo Finance APIs. -/// Verwaltet automatisch den erforderlichen Cookie- (A3) und Crumb-Token-Authentifizierungs-Flow. -/// -public interface IYahooFinanceClient -{ - /// - /// Stellt sicher, dass die aktuelle Session über ein gültiges Cookie und einen Crumb-Token verfügt. - /// - /// Erzwingt das Erneuern des Authentifizierungs-Tokens, selbst wenn die Frist noch nicht abgelaufen ist. - /// Ein Token zum Abbrechen der asynchronen Operation. - /// Der aktuelle Crumb-Token oder null, wenn die Authentifizierung fehlgeschlagen ist. - Task EnsureAuthenticatedAsync(bool forceRefresh = false, CancellationToken cancellationToken = default); - - /// - /// Sucht nach Tickern, Namen, ISINs oder Firmen über die Yahoo Finance Such-API. - /// erfordert keine Cookie/Crumb-Authentifizierung. - /// - /// Der Suchbegriff (z. B. "Apple", "US0378331005", "AAPL"). - /// Die maximale Anzahl an Treffern für Wertpapiere/Aktien. - /// Die maximale Anzahl an News-Treffern. - /// Ein Token zum Abbrechen der asynchronen Operation. - /// Das Suchergebnis-DTO oder null bei Fehlern. - Task SearchAsync( - string query, - int quotesCount = 10, - int newsCount = 0, - CancellationToken cancellationToken = default); - - /// - /// Ruft Fundamentaldaten und Unternehmens-Metadaten für ein bestimmtes Symbol über den quoteSummary-Endpunkt ab. - /// - /// Das Tickersymbol (z. B. "AAPL", "MSFT"). - /// Die abzufragenden Yahoo-Module (z. B. "assetProfile", "financialData"). - /// Ein Token zum Abbrechen der asynchronen Operation. - /// Die Abfrageergebnisse als DTO oder null bei Fehlern. - Task GetQuoteSummaryAsync( - string symbol, - IEnumerable modules, - CancellationToken cancellationToken = default); - - /// - /// Hilfsmethode zum Abrufen aller vordefinierten Standard-Module für ein Tickersymbol. - /// - /// Das Tickersymbol (z. B. "AAPL"). - /// Ein Token zum Abbrechen der asynchronen Operation. - /// Das vollständige QuoteSummary-DTO oder null bei Fehlern. - Task GetFullQuoteSummaryAsync( - string symbol, - CancellationToken cancellationToken = default); - - /// - /// Ruft historische Chart- und Kursdaten (OHLCV) für ein Symbol ab. - /// - /// Das Tickersymbol (z. B. "AAPL"). - /// Der Abfragezeitraum (z. B. "1d", "1m", "1y", "5y"). - /// Das Intervall der Datenpunkte (z. B. "1m", "5m", "1d", "1wk"). - /// Ein Token zum Abbrechen der asynchronen Operation. - /// Das Chart-Ergebnis-DTO oder null bei Fehlern. - Task GetChartAsync( - string symbol, - string range = "1y", - string interval = "1d", - CancellationToken cancellationToken = default); - - /// - /// Ruft schnelle Realtime-Preise für eine Liste von Tickersymbolen ab. - /// - /// Eine Liste von Tickersymbolen (z. B. ["AAPL", "MSFT", "^GSPC"]). - /// Ein Token zum Abbrechen der asynchronen Operation. - /// Das Quote-Ergebnis-DTO oder null bei Fehlern. - Task GetQuotesAsync( - IEnumerable symbols, - CancellationToken cancellationToken = default); - - /// - /// Bequeme Hilfsmethode, um den aktuellen regulären Marktpreis für ein einzelnes Tickersymbol abzufragen. - /// - /// Das Tickersymbol (z. B. "^VIX", "AAPL"). - /// Ein Token zum Abbrechen der asynchronen Operation. - /// Der aktuelle Preis als oder null, wenn kein Preis ermittelt werden konnte. - Task GetLivePriceAsync( - string symbol, - CancellationToken cancellationToken = default); -} - -/// -/// Managed thread-safe HTTP client for Yahoo Finance APIs. -/// Implements the two-step Cookie (A3) & Crumb token authentication flow. -/// public class YahooFinanceClient { - private const string DefaultUserAgent = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; - private readonly HttpClient _httpClient; private readonly CookieContainer _cookieContainer; - private readonly ILogger? _logger; + private readonly IFinlyticLogger? _finlyticLogger; + private readonly ISettingsService? _settingsService; private readonly SemaphoreSlim _authLock = new(1, 1); private string? _crumb; @@ -133,9 +41,13 @@ public class YahooFinanceClient "calendarEvents" }; - public YahooFinanceClient(ILogger? logger = null, HttpClient? httpClient = null) + public YahooFinanceClient( + IFinlyticLogger? finlyticLogger = null, + ISettingsService? settingsService = null, + HttpClient? httpClient = null) { - _logger = logger; + _finlyticLogger = finlyticLogger; + _settingsService = settingsService; _cookieContainer = new CookieContainer(); if (httpClient != null) @@ -147,71 +59,137 @@ public class YahooFinanceClient var handler = new HttpClientHandler { CookieContainer = _cookieContainer, - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + UseCookies = true, + AllowAutoRedirect = true }; - _httpClient = new HttpClient(handler); - } - if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent")) - { - _httpClient.DefaultRequestHeaders.Add("User-Agent", DefaultUserAgent); + _httpClient = new HttpClient(handler); + _httpClient.DefaultRequestHeaders.Add("User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); } } /// - /// Executes the Cookie (A3) & Crumb token authentication flow. - /// 1. GET https://fc.yahoo.com (sets session A3 cookie) - /// 2. GET https://query1.finance.yahoo.com/v1/test/getcrumb (returns crumb string) + /// Ensures that an active Yahoo session (Cookie + dynamic Crumb token) is initialized. + /// Uses persistent DB caching and only refreshes when the crumb is invalid or forceRefresh is true. /// public async Task EnsureAuthenticatedAsync(bool forceRefresh = false, CancellationToken cancellationToken = default) { - if (!forceRefresh && !string.IsNullOrWhiteSpace(_crumb) && (DateTime.UtcNow - _lastAuthTime).TotalHours < 12) - { - return _crumb; - } - await _authLock.WaitAsync(cancellationToken); try { - if (!forceRefresh && !string.IsNullOrWhiteSpace(_crumb) && - (DateTime.UtcNow - _lastAuthTime).TotalHours < 12) + // 1. Check in-memory crumb + if (!forceRefresh && !string.IsNullOrWhiteSpace(_crumb)) { return _crumb; } - _logger?.LogInformation("[YahooFinanceClient] Authenticating session (Cookie + Crumb)..."); + // 2. Check persistent DB cache via SettingsService + if (!forceRefresh && _settingsService != null) + { + try + { + var cachedCrumb = await _settingsService.GetSettingAsync(CoreSettingKeys.YahooAuthCrumb, cancellationToken); + var cachedCookies = await _settingsService.GetSettingAsync(CoreSettingKeys.YahooAuthCookie, cancellationToken); - // 1. Send GET request to fc.yahoo.com to obtain session cookie A3 + if (!string.IsNullOrWhiteSpace(cachedCrumb) && !string.IsNullOrWhiteSpace(cachedCookies)) + { + RestoreCookies(cachedCookies); + _crumb = cachedCrumb; + _lastAuthTime = DateTime.UtcNow; + + if (_finlyticLogger != null) + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Restored cached Yahoo session & crumb from database ({Crumb}).", _crumb); + return _crumb; + } + } + catch (Exception ex) + { + if (_finlyticLogger != null) + await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, ex, "[YahooFinanceClient] Error restoring cached Yahoo session from DB."); + } + } + + if (_finlyticLogger != null) + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Authenticating fresh session with Yahoo (Cookie + Crumb)..."); + + // 3. Send GET request to fc.yahoo.com to obtain session cookie A3 using (var initRequest = new HttpRequestMessage(HttpMethod.Get, "https://fc.yahoo.com")) { + initRequest.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); + initRequest.Headers.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"); using var initResponse = await _httpClient.SendAsync(initRequest, cancellationToken); - // CookieContainer automatically intercepts and stores 'A3' cookie } - // 2. Send GET request to getcrumb to obtain the dynamic crumb token - using (var crumbRequest = - new HttpRequestMessage(HttpMethod.Get, "https://query1.finance.yahoo.com/v1/test/getcrumb")) + // 4. Send GET request to getcrumb to obtain dynamic crumb token + string[] crumbUrls = new[] { - using var crumbResponse = await _httpClient.SendAsync(crumbRequest, cancellationToken); - if (!crumbResponse.IsSuccessStatusCode) + "https://query1.finance.yahoo.com/v1/test/getcrumb", + "https://query2.finance.yahoo.com/v1/test/getcrumb" + }; + + foreach (var url in crumbUrls) + { + try { - _logger?.LogWarning("[YahooFinanceClient] Failed to fetch crumb token. Status: {Status}", - crumbResponse.StatusCode); - return null; + using var crumbRequest = new HttpRequestMessage(HttpMethod.Get, url); + crumbRequest.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); + crumbRequest.Headers.Add("Accept", "*/*"); + + using var crumbResponse = await _httpClient.SendAsync(crumbRequest, cancellationToken); + if (crumbResponse.IsSuccessStatusCode) + { + var crumbText = await crumbResponse.Content.ReadAsStringAsync(cancellationToken); + if (!string.IsNullOrWhiteSpace(crumbText)) + { + _crumb = crumbText.Trim('"', ' ', '\t', '\r', '\n'); + _lastAuthTime = DateTime.UtcNow; + + // Persist to DB cache via SettingsService + if (_settingsService != null) + { + try + { + var serializedCookies = SerializeCookies(); + await _settingsService.SetSettingAsync(CoreSettingKeys.YahooAuthCrumb, _crumb, cancellationToken); + if (!string.IsNullOrWhiteSpace(serializedCookies)) + { + await _settingsService.SetSettingAsync(CoreSettingKeys.YahooAuthCookie, serializedCookies, cancellationToken); + } + } + catch (Exception persistEx) + { + if (_finlyticLogger != null) + await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, persistEx, "[YahooFinanceClient] Failed to persist new Yahoo session to database."); + } + } + + if (_finlyticLogger != null) + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Acquired fresh Crumb token successfully and persisted: {Crumb}", _crumb); + return _crumb; + } + } + else + { + if (_finlyticLogger != null) + await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Endpoint '{Url}' returned status {Status}", url, crumbResponse.StatusCode); + } + } + catch + { + // Fallthrough to next endpoint } - - var crumbText = await crumbResponse.Content.ReadAsStringAsync(cancellationToken); - _crumb = crumbText.Trim('"', ' ', '\t', '\r', '\n'); - _lastAuthTime = DateTime.UtcNow; - - _logger?.LogInformation("[YahooFinanceClient] Acquired Crumb token successfully: {Crumb}", _crumb); - return _crumb; } + + if (_finlyticLogger != null) + await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Failed to fetch crumb token from all endpoints."); + return null; } catch (Exception ex) { - _logger?.LogError(ex, "[YahooFinanceClient] Exception during Cookie & Crumb authentication."); + if (_finlyticLogger != null) + await _finlyticLogger.LogErrorAsync(CoreSettingKeys.YahooClientChannel, ex, "[YahooFinanceClient] Exception during Cookie & Crumb authentication."); return null; } finally @@ -220,6 +198,48 @@ public class YahooFinanceClient } } + private string SerializeCookies() + { + try + { + var cookies = _cookieContainer.GetAllCookies(); + var pairs = new List(); + foreach (Cookie cookie in cookies) + { + pairs.Add($"{cookie.Name}={cookie.Value}"); + } + return string.Join(";", pairs); + } + catch + { + return string.Empty; + } + } + + private void RestoreCookies(string serializedCookies) + { + if (string.IsNullOrWhiteSpace(serializedCookies)) return; + + try + { + var parts = serializedCookies.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var part in parts) + { + var eqIdx = part.IndexOf('='); + if (eqIdx > 0 && eqIdx < part.Length - 1) + { + var name = part.Substring(0, eqIdx).Trim(); + var val = part.Substring(eqIdx + 1).Trim(); + _cookieContainer.Add(new Cookie(name, val, "/", ".yahoo.com")); + } + } + } + catch + { + // Ignore cookie restore errors + } + } + /// /// Searches for tickers, names, ISINs, or companies via the Yahoo Finance search API. /// URL: https://query2.finance.yahoo.com/v1/finance/search?q={query}&quotesCount={quotesCount}&newsCount={newsCount} @@ -241,8 +261,8 @@ public class YahooFinanceClient if (!response.IsSuccessStatusCode) { - _logger?.LogWarning("[YahooFinanceClient] Search for '{Query}' failed with status {Status}", query, - response.StatusCode); + if (_finlyticLogger != null) + await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Search for '{Query}' failed with status {Status}", query, response.StatusCode); return null; } @@ -251,7 +271,8 @@ public class YahooFinanceClient } catch (Exception ex) { - _logger?.LogError(ex, "[YahooFinanceClient] Exception during Search for query '{Query}'", query); + if (_finlyticLogger != null) + await _finlyticLogger.LogErrorAsync(CoreSettingKeys.YahooClientChannel, ex, "[YahooFinanceClient] Exception during Search for query '{Query}'", query); return null; } } @@ -276,8 +297,8 @@ public class YahooFinanceClient if (!response.IsSuccessStatusCode) { - _logger?.LogWarning("[YahooFinanceClient] GetQuoteSummary for '{Symbol}' failed with status {Status}", - symbol, response.StatusCode); + if (_finlyticLogger != null) + await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] GetQuoteSummary for '{Symbol}' failed with status {Status}", symbol, response.StatusCode); return ( response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden, null); @@ -318,8 +339,8 @@ public class YahooFinanceClient if (!response.IsSuccessStatusCode) { - _logger?.LogWarning("[YahooFinanceClient] GetChart for '{Symbol}' failed with status {Status}", symbol, - response.StatusCode); + if (_finlyticLogger != null) + await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] GetChart for '{Symbol}' failed with status {Status}", symbol, response.StatusCode); return ( response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden, null); @@ -351,7 +372,8 @@ public class YahooFinanceClient if (!response.IsSuccessStatusCode) { - _logger?.LogWarning("[YahooFinanceClient] GetQuotes failed with status {Status}", response.StatusCode); + if (_finlyticLogger != null) + await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] GetQuotes failed with status {Status}", response.StatusCode); return ( response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden, null); @@ -396,8 +418,8 @@ public class YahooFinanceClient if (isAuthError) { - _logger?.LogInformation( - "[YahooFinanceClient] Authentication error encountered (401/403). Re-authenticating..."); + if (_finlyticLogger != null) + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Authentication error encountered (401/403). Re-authenticating..."); crumb = await EnsureAuthenticatedAsync(true, cancellationToken); if (string.IsNullOrEmpty(crumb)) return null; @@ -413,7 +435,7 @@ public class YahooFinanceClient return new JsonSerializerOptions { PropertyNameCaseInsensitive = true, - NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString + PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; } } \ No newline at end of file diff --git a/FinlyticCore/Clients/YahooFinanceHtmlClient.cs b/FinlyticCore/Clients/YahooFinanceHtmlClient.cs index 119d1fa..f35793b 100644 --- a/FinlyticCore/Clients/YahooFinanceHtmlClient.cs +++ b/FinlyticCore/Clients/YahooFinanceHtmlClient.cs @@ -9,7 +9,6 @@ using FinlyticCore.Dtos.Yahoo; using FinlyticCore.Models.Settings; using FinlyticCore.Services; using FinlyticCore.Services.PlaywrightScrapper; -using Microsoft.EntityFrameworkCore; using Microsoft.Playwright; namespace FinlyticCore.Clients; @@ -21,25 +20,18 @@ public interface IYahooFinanceHtmlClient CancellationToken cancellationToken = default); } -public interface IYahooFinanceHtmlClient : IYahooFinanceHtmlClient - where TDbContext : DbContext -{ -} - -public class YahooFinanceHtmlClient : IYahooFinanceHtmlClient - where TDbContext : DbContext +public class YahooFinanceHtmlClient : IYahooFinanceHtmlClient { + private const string _serviceName = nameof(YahooFinanceHtmlClient); private readonly IPlaywrightExecutionService _playwrightService; - private readonly IFinlyticLogger _finlyticLogger; - private readonly string _serviceName; + private readonly IFinlyticLogger _finlyticLogger; public YahooFinanceHtmlClient( IPlaywrightExecutionService playwrightService, - IFinlyticLogger finlyticLogger) + IFinlyticLogger finlyticLogger) { _playwrightService = playwrightService; _finlyticLogger = finlyticLogger; - _serviceName = typeof(TContextClass).Name; } public async Task ScrapeQuoteSummaryModulesAsync( diff --git a/FinlyticCore/Database/ISettingsDbContext.cs b/FinlyticCore/Database/ISettingsDbContext.cs new file mode 100644 index 0000000..3e8b40a --- /dev/null +++ b/FinlyticCore/Database/ISettingsDbContext.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Entities.Settings; +using Microsoft.EntityFrameworkCore; + +namespace FinlyticCore.Database; + +/// +/// Einheitliches Interface für DbContexts, die dynamische Einstellungen verwalten. +/// +public interface ISettingsDbContext +{ + DbSet DynamicSettings { get; } + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/FinlyticCore/Dtos/Logging/LogMessageDto.cs b/FinlyticCore/Dtos/Logging/LogMessageDto.cs new file mode 100644 index 0000000..d933178 --- /dev/null +++ b/FinlyticCore/Dtos/Logging/LogMessageDto.cs @@ -0,0 +1,13 @@ +using System; +using System.Text.Json.Serialization; + +namespace FinlyticCore.Dtos.Logging; + +public record LogMessageDto( + [property: JsonPropertyName("timestamp")] DateTime Timestamp, + [property: JsonPropertyName("serviceName")] string ServiceName, + [property: JsonPropertyName("channel")] string Channel, + [property: JsonPropertyName("level")] string Level, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("exception")] string? Exception = null +); diff --git a/FinlyticCore/Dtos/Settings/DynamicSettingDto.cs b/FinlyticCore/Dtos/Settings/DynamicSettingDto.cs new file mode 100644 index 0000000..484756c --- /dev/null +++ b/FinlyticCore/Dtos/Settings/DynamicSettingDto.cs @@ -0,0 +1,14 @@ +using System; + +namespace FinlyticCore.Dtos.Settings; + +/// +/// Repräsentiert eine dynamische Einstellung für das Web-UI und MQTT-RPC. +/// +public record DynamicSettingDto( + string Key, + object? Value, + string Type, + string Description = "", + DateTime? UpdatedAt = null +); diff --git a/FinlyticCore/FinlyticCore.csproj b/FinlyticCore/FinlyticCore.csproj index d1ad2e7..36ed671 100644 --- a/FinlyticCore/FinlyticCore.csproj +++ b/FinlyticCore/FinlyticCore.csproj @@ -9,6 +9,8 @@ + + diff --git a/FinlyticCore/Models/Settings/CoreSettingKeys.cs b/FinlyticCore/Models/Settings/CoreSettingKeys.cs index aaac5ca..755b810 100644 --- a/FinlyticCore/Models/Settings/CoreSettingKeys.cs +++ b/FinlyticCore/Models/Settings/CoreSettingKeys.cs @@ -5,16 +5,27 @@ namespace FinlyticCore.Models.Settings; /// public static class CoreSettingKeys { - // --- Logging-Kanäle --- + // --- Globale Logging-Kanäle --- public static readonly SettingKey HealthPingChannel = new("Logging.Channel.Health", true); public static readonly SettingKey MqttChannel = new("Logging.Channel.MQTT", true); public static readonly SettingKey HtmlScrapperChannel = new("Logging.Channel.HtmlScrapper", true); public static readonly SettingKey YahooClientChannel = new("Logging.Channel.YahooClient", true); public static readonly SettingKey FundamentalsChannel = new("Logging.Channel.Fundamentals", true); + public static readonly SettingKey TradeRepublicChannel = new("Logging.Channel.TradeRepublic", true); + public static readonly SettingKey PlaywrightChannel = new("Logging.Channel.Playwright", true); + public static readonly SettingKey SettingsChannel = new("Logging.Channel.Settings", true); // --- Scraper & Feature-Toggles --- public static readonly SettingKey EnableHtmlFallback = new("Feature.EnableHtmlFallback", true); public static readonly SettingKey AllowForceRefresh = new("Feature.AllowForceRefresh", true); public static readonly SettingKey ScraperTimeoutSeconds = new("Scraper.TimeoutSeconds", 30); public static readonly SettingKey ScraperMaxRetries = new("Scraper.MaxRetries", 2); + + // --- Trade Republic WebSocket Config --- + public static readonly SettingKey TradeRepublicWsReconnectIntervalSeconds = new("TradeRepublic.WsReconnectIntervalSeconds", 5); + public static readonly SettingKey TradeRepublicWsTimeoutSeconds = new("TradeRepublic.WsTimeoutSeconds", 15); + + // --- Yahoo Auth Persistence --- + public static readonly SettingKey YahooAuthCrumb = new("Yahoo.Auth.Crumb", ""); + public static readonly SettingKey YahooAuthCookie = new("Yahoo.Auth.Cookie", ""); } diff --git a/FinlyticCore/Services/FinlyticLogger/FinlyticLogger.cs b/FinlyticCore/Services/FinlyticLogger/FinlyticLogger.cs index b1d1203..3ce4b91 100644 --- a/FinlyticCore/Services/FinlyticLogger/FinlyticLogger.cs +++ b/FinlyticCore/Services/FinlyticLogger/FinlyticLogger.cs @@ -1,17 +1,42 @@ using System; using System.Threading.Tasks; +using FinlyticCore.Dtos.Logging; using FinlyticCore.Models.Settings; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace FinlyticCore.Services; /// -/// Bietet kanalbasierte, dynamisch steuerbare Logging-Funktionalitäten über den . +/// Globaler Broadcaster für strukturierte Logs in Echtzeit. +/// +public static class FinlyticLogBroadcaster +{ + public static Func? OnLogPublished { get; set; } + + public static void Broadcast(LogMessageDto dto) + { + if (OnLogPublished != null) + { + _ = Task.Run(async () => + { + try + { + await OnLogPublished(dto); + } + catch + { + // Ignore broadcast errors to never disrupt execution + } + }); + } + } +} + +/// +/// Bietet kanalbasierte, dynamisch steuerbare Logging-Funktionalitäten über den . /// /// Die aufrufende Klasse (für Log-Kategorien). -/// Der DbContext des Services für den Zugriff auf die Settings. -public interface IFinlyticLogger where TDbContext : DbContext +public interface IFinlyticLogger { // --- Debug --- Task LogDebugAsync(SettingKey channelKey, string message, params object[] args); @@ -36,22 +61,49 @@ public interface IFinlyticLogger where TDbContext : D /// /// Kanalbasierte Logger-Implementierung, die Einstellungen und Stummschaltungen -/// in Echtzeit aus dem bezieht. +/// in Echtzeit aus dem bezieht. /// -public class FinlyticLogger : IFinlyticLogger - where TDbContext : DbContext +public class FinlyticLogger : IFinlyticLogger { + private static readonly string ServiceName = typeof(TContextClass).Assembly.GetName().Name ?? "Finlytic"; private readonly ILogger _logger; - private readonly ISettingsService _settingsService; + private readonly ISettingsService _settingsService; public FinlyticLogger( ILogger logger, - ISettingsService settingsService) + ISettingsService settingsService) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); } + private void DispatchBroadcast(SettingKey channelKey, LogLevel level, string message, Exception? exception, params object[] args) + { + try + { + string formattedMsg = args != null && args.Length > 0 ? string.Format(message, args) : message; + FinlyticLogBroadcaster.Broadcast(new LogMessageDto( + Timestamp: DateTime.UtcNow, + ServiceName: ServiceName, + Channel: channelKey.Name, + Level: level.ToString(), + Message: formattedMsg, + Exception: exception?.ToString() + )); + } + catch + { + FinlyticLogBroadcaster.Broadcast(new LogMessageDto( + Timestamp: DateTime.UtcNow, + ServiceName: ServiceName, + Channel: channelKey.Name, + Level: level.ToString(), + Message: message, + Exception: exception?.ToString() + )); + } + } + #region Debug public async Task LogDebugAsync(SettingKey channelKey, string message, params object[] args) @@ -59,6 +111,7 @@ public class FinlyticLogger : IFinlyticLogger : IFinlyticLogger : IFinlyticLogger : IFinlyticLogger : IFinlyticLogger : IFinlyticLogger : IFinlyticLogger : IFinlyticLogger : IFinlyticLogger : IFinlyticLogger - /// Prüft, ob ein spezifischer Kanal und die aufrufende Klasse aktives Logging erlauben. - /// private async Task ShouldLogAsync(SettingKey channelKey, LogLevel level) { ArgumentNullException.ThrowIfNull(channelKey); diff --git a/FinlyticCore/Services/PlaywrightScrapper/PlaywrightBrowserFactory.cs b/FinlyticCore/Services/PlaywrightScrapper/PlaywrightBrowserFactory.cs index 549bc95..fb485b7 100644 --- a/FinlyticCore/Services/PlaywrightScrapper/PlaywrightBrowserFactory.cs +++ b/FinlyticCore/Services/PlaywrightScrapper/PlaywrightBrowserFactory.cs @@ -1,9 +1,14 @@ -using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Models.Settings; +using FinlyticCore.Services; using Microsoft.Playwright; namespace FinlyticCore.Services.PlaywrightScrapper; -public interface IPlaywrightBrowserFactory : IAsyncDisposable +public interface IPlaywrightBrowserFactory : IAsyncDisposable, IDisposable { /// /// Stellt sicher, dass die IBrowser-Instanz verbunden ist. @@ -18,15 +23,15 @@ public interface IPlaywrightBrowserFactory : IAsyncDisposable public class PlaywrightBrowserFactory : IPlaywrightBrowserFactory { - private readonly ILogger _logger; + private readonly IFinlyticLogger _finlyticLogger; private readonly SemaphoreSlim _browserLock = new(1, 1); private IPlaywright? _playwright; private IBrowser? _browser; - public PlaywrightBrowserFactory(ILogger logger) + public PlaywrightBrowserFactory(IFinlyticLogger finlyticLogger) { - _logger = logger; + _finlyticLogger = finlyticLogger; } public async Task GetBrowserAsync(CancellationToken cancellationToken = default) @@ -51,7 +56,7 @@ public class PlaywrightBrowserFactory : IPlaywrightBrowserFactory } }); - _logger.LogInformation("[PlaywrightFactory] Shared Chromium Instance successfully launched."); + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.PlaywrightChannel, "[PlaywrightFactory] Shared Chromium Instance successfully launched."); return _browser; } finally @@ -79,12 +84,41 @@ public class PlaywrightBrowserFactory : IPlaywrightBrowserFactory } }; + public void Dispose() + { + try + { + if (_browser != null) + { + _browser.CloseAsync().GetAwaiter().GetResult(); + _browser.DisposeAsync().GetAwaiter().GetResult(); + } + } + catch + { + // Ignore any sync disposal timeouts + } + finally + { + _playwright?.Dispose(); + _browserLock.Dispose(); + GC.SuppressFinalize(this); + } + } + public async ValueTask DisposeAsync() { if (_browser != null) { - await _browser.CloseAsync(); - await _browser.DisposeAsync(); + try + { + await _browser.CloseAsync(); + await _browser.DisposeAsync(); + } + catch + { + // Ignore disposal errors + } } _playwright?.Dispose(); diff --git a/FinlyticCore/Services/Settings/SettingsService.cs b/FinlyticCore/Services/Settings/SettingsService.cs index dce6901..01cf375 100644 --- a/FinlyticCore/Services/Settings/SettingsService.cs +++ b/FinlyticCore/Services/Settings/SettingsService.cs @@ -1,50 +1,54 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using FinlyticCore.Database; +using FinlyticCore.Dtos.Settings; using FinlyticCore.Entities.Settings; using FinlyticCore.Models.Settings; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace FinlyticCore.Services; -public interface ISettingsService where TContext : DbContext +public interface ISettingsService { // --- 1. Typsicherer Zugriff über SettingKey (Empfohlen) --- - Task GetSettingAsync(SettingKey key, - CancellationToken cancellationToken = default); - - Task SetSettingAsync(SettingKey key, T value, - CancellationToken cancellationToken = default); + Task GetSettingAsync(SettingKey key, CancellationToken cancellationToken = default); + Task SetSettingAsync(SettingKey key, T value, CancellationToken cancellationToken = default); // --- 2. Dynamischer Zugriff über Enum-Key --- - Task GetSettingAsync(TEnum enumKey, T defaultValue = default!, - CancellationToken cancellationToken = default) where TEnum : struct, Enum; - - Task SetSettingAsync(TEnum enumKey, T value, - CancellationToken cancellationToken = default) where TEnum : struct, Enum; + Task GetSettingAsync(TEnum enumKey, T defaultValue = default!, CancellationToken cancellationToken = default) where TEnum : struct, Enum; + Task SetSettingAsync(TEnum enumKey, T value, CancellationToken cancellationToken = default) where TEnum : struct, Enum; // --- 3. Dynamischer Zugriff über String-Key --- - Task GetSettingAsync(string key, T defaultValue = default!, - CancellationToken cancellationToken = default); + Task GetSettingAsync(string key, T defaultValue = default!, CancellationToken cancellationToken = default); + Task SetSettingAsync(string key, T value, CancellationToken cancellationToken = default); - Task SetSettingAsync(string key, T value, - CancellationToken cancellationToken = default); + // --- 4. Reflection-Erkennung & Bulk-Verwaltung für Web UI / MQTT --- + Task> GetAllRegisteredSettingsAsync(IEnumerable? customKeyHolders = null, CancellationToken cancellationToken = default); + Task UpdateSettingsAsync(Dictionary updatedSettings, CancellationToken cancellationToken = default); } -public class SettingsService : ISettingsService where TContext : DbContext +public class SettingsService : ISettingsService { - private readonly Microsoft.Extensions.DependencyInjection.IServiceScopeFactory _scopeFactory; - private readonly ILogger>? _logger; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger? _logger; - // Fast In-Memory Cache: Key Schema: "KeyName" - private readonly ConcurrentDictionary _cache = new(); + // Fast In-Memory Cache: Key Schema: "KeyName" -> JSON string + private readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); - public SettingsService(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory scopeFactory, ILogger>? logger = null) + public SettingsService( + IServiceScopeFactory scopeFactory, + ILogger? logger = null) { - _scopeFactory = scopeFactory; + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); _logger = logger; } @@ -52,11 +56,13 @@ public class SettingsService : ISettingsService where TConte public Task GetSettingAsync(SettingKey key, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(key); return GetSettingInternalAsync(key.Name, key.DefaultValue, cancellationToken); } public Task SetSettingAsync(SettingKey key, T value, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(key); return SetSettingInternalAsync(key.Name, value, cancellationToken); } @@ -96,11 +102,138 @@ public class SettingsService : ISettingsService where TConte #endregion - #region Core Engine Logik + #region Bulk & Reflection Discovery + + public async Task> GetAllRegisteredSettingsAsync( + IEnumerable? customKeyHolders = null, + CancellationToken cancellationToken = default) + { + var holderTypes = new List { typeof(CoreSettingKeys) }; + if (customKeyHolders != null) + { + holderTypes.AddRange(customKeyHolders); + } + + var resultList = new List(); + var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + // 1. Reflection auf allen SettingKey Feldern + foreach (var type in holderTypes.Distinct()) + { + var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy); + foreach (var field in fields) + { + var fieldType = field.FieldType; + if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(SettingKey<>)) + { + var valType = fieldType.GetGenericArguments()[0]; + var settingKeyObj = field.GetValue(null); + if (settingKeyObj == null) continue; + + var nameProp = fieldType.GetProperty("Name"); + var defaultProp = fieldType.GetProperty("DefaultValue"); + + var keyName = nameProp?.GetValue(settingKeyObj)?.ToString() ?? field.Name; + if (seenKeys.Contains(keyName)) continue; + seenKeys.Add(keyName); + + var defVal = defaultProp?.GetValue(settingKeyObj); + var typeName = MapToSimpleTypeName(valType); + + // Aktuellen Wert aus DB / Cache lesen + var currentRawValue = await GetSettingInternalObjectAsync(keyName, valType, defVal, cancellationToken); + + resultList.Add(new DynamicSettingDto( + Key: keyName, + Value: currentRawValue, + Type: typeName, + Description: FormatDescriptionFromKey(keyName), + UpdatedAt: DateTime.UtcNow + )); + } + } + } + + // 2. Prüfen, ob in der DB weitere gespeicherte Settings existieren, die nicht im Code deklariert sind + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetService(); + if (dbContext != null) + { + var dbSettings = await dbContext.DynamicSettings.AsNoTracking().ToListAsync(cancellationToken); + foreach (var dbSetting in dbSettings) + { + if (!seenKeys.Contains(dbSetting.Key)) + { + seenKeys.Add(dbSetting.Key); + var (inferredVal, inferredType) = InferJsonValueAndType(dbSetting.ValueJson); + resultList.Add(new DynamicSettingDto( + Key: dbSetting.Key, + Value: inferredVal, + Type: inferredType, + Description: FormatDescriptionFromKey(dbSetting.Key), + UpdatedAt: dbSetting.LastUpdatedUtc + )); + } + } + } + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "[SettingsService] Error reading database settings during GetAllRegisteredSettingsAsync."); + } + + return resultList.OrderBy(s => s.Key).ToList(); + } + + public async Task UpdateSettingsAsync(Dictionary updatedSettings, CancellationToken cancellationToken = default) + { + if (updatedSettings == null || updatedSettings.Count == 0) return; + + await using var scope = _scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetService(); + + foreach (var (key, rawValue) in updatedSettings) + { + if (string.IsNullOrWhiteSpace(key)) continue; + + string jsonValue = NormalizeJsonValue(rawValue); + _cache[key] = jsonValue; + + if (dbContext != null) + { + var entity = await dbContext.DynamicSettings.FirstOrDefaultAsync(s => s.Key == key, cancellationToken); + if (entity == null) + { + entity = new SettingEntity + { + Key = key, + ValueJson = jsonValue, + LastUpdatedUtc = DateTime.UtcNow + }; + dbContext.DynamicSettings.Add(entity); + } + else + { + entity.ValueJson = jsonValue; + entity.LastUpdatedUtc = DateTime.UtcNow; + } + } + } + + if (dbContext != null) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + #endregion + + #region Internal Engine Logic private async Task GetSettingInternalAsync(string key, T defaultValue, CancellationToken cancellationToken) { - // 1. Zuerst im In-Memory Cache prüfen if (_cache.TryGetValue(key, out var cachedJson)) { return DeserializeValue(cachedJson, defaultValue); @@ -108,17 +241,21 @@ public class SettingsService : ISettingsService where TConte try { - using var scope = _scopeFactory.CreateScope(); - var dbContext = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(scope.ServiceProvider); + await using var scope = _scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetService(); + if (dbContext == null) + { + var defaultJson = NormalizeJsonValue(defaultValue); + _cache[key] = defaultJson; + return defaultValue; + } - // 2. Aus DB der spezifischen TContext-Instanz laden - var entity = await dbContext.Set() + var entity = await dbContext.DynamicSettings .FirstOrDefaultAsync(s => s.Key == key, cancellationToken); - // 3. Falls noch nicht vorhanden: In DB anlegen (Seed on Demand) if (entity == null) { - var defaultJson = JsonSerializer.Serialize(defaultValue); + var defaultJson = NormalizeJsonValue(defaultValue); entity = new SettingEntity { Key = key, @@ -126,35 +263,76 @@ public class SettingsService : ISettingsService where TConte LastUpdatedUtc = DateTime.UtcNow }; - dbContext.Set().Add(entity); + dbContext.DynamicSettings.Add(entity); await dbContext.SaveChangesAsync(cancellationToken); _cache[key] = defaultJson; return defaultValue; } - // In Cache legen & Wert zurückgeben _cache[key] = entity.ValueJson; return DeserializeValue(entity.ValueJson, defaultValue); } catch (Exception ex) { - _logger?.LogWarning(ex, "Setting '{Key}' could not be loaded or initialized in DB. Using default value in memory.", key); - _cache[key] = JsonSerializer.Serialize(defaultValue); + _logger?.LogWarning(ex, "[SettingsService] Setting '{Key}' could not be loaded or initialized in DB. Using default value.", key); + _cache[key] = NormalizeJsonValue(defaultValue); + return defaultValue; + } + } + + private async Task GetSettingInternalObjectAsync(string key, Type valueType, object? defaultValue, CancellationToken cancellationToken) + { + if (_cache.TryGetValue(key, out var cachedJson)) + { + return DeserializeObject(cachedJson, valueType, defaultValue); + } + + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetService(); + if (dbContext == null) return defaultValue; + + var entity = await dbContext.DynamicSettings.FirstOrDefaultAsync(s => s.Key == key, cancellationToken); + if (entity == null) + { + var defaultJson = NormalizeJsonValue(defaultValue); + entity = new SettingEntity + { + Key = key, + ValueJson = defaultJson, + LastUpdatedUtc = DateTime.UtcNow + }; + + dbContext.DynamicSettings.Add(entity); + await dbContext.SaveChangesAsync(cancellationToken); + + _cache[key] = defaultJson; + return defaultValue; + } + + _cache[key] = entity.ValueJson; + return DeserializeObject(entity.ValueJson, valueType, defaultValue); + } + catch + { return defaultValue; } } private async Task SetSettingInternalAsync(string key, T value, CancellationToken cancellationToken) { - var jsonValue = JsonSerializer.Serialize(value); + var jsonValue = NormalizeJsonValue(value); + _cache[key] = jsonValue; try { - using var scope = _scopeFactory.CreateScope(); - var dbContext = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(scope.ServiceProvider); + await using var scope = _scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetService(); + if (dbContext == null) return; - var entity = await dbContext.Set() + var entity = await dbContext.DynamicSettings .FirstOrDefaultAsync(s => s.Key == key, cancellationToken); if (entity == null) @@ -165,7 +343,7 @@ public class SettingsService : ISettingsService where TConte ValueJson = jsonValue, LastUpdatedUtc = DateTime.UtcNow }; - dbContext.Set().Add(entity); + dbContext.DynamicSettings.Add(entity); } else { @@ -177,17 +355,91 @@ public class SettingsService : ISettingsService where TConte } catch (Exception ex) { - _logger?.LogWarning(ex, "Setting '{Key}' could not be saved to DB.", key); + _logger?.LogWarning(ex, "[SettingsService] Setting '{Key}' could not be saved to DB.", key); + } + } + + private static string NormalizeJsonValue(object? rawValue) + { + if (rawValue == null) return "null"; + + if (rawValue is JsonElement jsonElem) + { + if (jsonElem.ValueKind == JsonValueKind.String) + { + var str = jsonElem.GetString()?.Trim() ?? string.Empty; + var unquoted = str.Trim('\"', ' '); + if (bool.TryParse(unquoted, out var b)) return b ? "true" : "false"; + if (long.TryParse(unquoted, out var l)) return l.ToString(); + if (double.TryParse(unquoted, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) return d.ToString(CultureInfo.InvariantCulture); + return JsonSerializer.Serialize(str); + } + return jsonElem.GetRawText(); } - // Cache trotzdem aktualisieren - _cache[key] = jsonValue; + if (rawValue is string s) + { + var unquoted = s.Trim('\"', ' '); + if (bool.TryParse(unquoted, out var b)) return b ? "true" : "false"; + if (long.TryParse(unquoted, out var l)) return l.ToString(); + if (double.TryParse(unquoted, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) return d.ToString(CultureInfo.InvariantCulture); + return JsonSerializer.Serialize(s); + } + + if (rawValue is bool bVal) return bVal ? "true" : "false"; + if (rawValue is int or long or short or byte) return rawValue.ToString()!; + if (rawValue is double or float or decimal) return Convert.ToString(rawValue, CultureInfo.InvariantCulture)!; + + return JsonSerializer.Serialize(rawValue); } private static T DeserializeValue(string json, T defaultValue) { + if (string.IsNullOrWhiteSpace(json)) return defaultValue; + try { + var unquoted = json.Trim('\"', ' '); + + if (typeof(T) == typeof(bool)) + { + if (bool.TryParse(unquoted, out var b)) + { + return (T)(object)b; + } + } + else if (typeof(T) == typeof(int)) + { + if (int.TryParse(unquoted, out var i)) + { + return (T)(object)i; + } + } + else if (typeof(T) == typeof(long)) + { + if (long.TryParse(unquoted, out var l)) + { + return (T)(object)l; + } + } + else if (typeof(T) == typeof(double)) + { + if (double.TryParse(unquoted, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) + { + return (T)(object)d; + } + } + else if (typeof(T) == typeof(string)) + { + var trimmed = json.Trim(); + if (trimmed.StartsWith("\"") && trimmed.EndsWith("\"") && trimmed.Length >= 2) + { + try { return (T)(object)(JsonSerializer.Deserialize(trimmed) ?? trimmed.Trim('\"')); } + catch { return (T)(object)trimmed.Trim('\"'); } + } + return (T)(object)trimmed; + } + var result = JsonSerializer.Deserialize(json); return result ?? defaultValue; } @@ -197,5 +449,106 @@ public class SettingsService : ISettingsService where TConte } } + private static object? DeserializeObject(string json, Type valueType, object? defaultValue) + { + if (string.IsNullOrWhiteSpace(json)) return defaultValue; + + try + { + var unquoted = json.Trim('\"', ' '); + + if (valueType == typeof(bool)) + { + if (bool.TryParse(unquoted, out var b)) return b; + } + else if (valueType == typeof(int)) + { + if (int.TryParse(unquoted, out var i)) return i; + } + else if (valueType == typeof(long)) + { + if (long.TryParse(unquoted, out var l)) return l; + } + else if (valueType == typeof(double)) + { + if (double.TryParse(unquoted, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) return d; + } + else if (valueType == typeof(string)) + { + var trimmed = json.Trim(); + if (trimmed.StartsWith("\"") && trimmed.EndsWith("\"") && trimmed.Length >= 2) + { + try { return JsonSerializer.Deserialize(trimmed) ?? trimmed.Trim('\"'); } + catch { return trimmed.Trim('\"'); } + } + return trimmed; + } + + return JsonSerializer.Deserialize(json, valueType) ?? defaultValue; + } + catch + { + return defaultValue; + } + } + + private static string MapToSimpleTypeName(Type type) + { + if (type == typeof(bool)) return "bool"; + if (type == typeof(int) || type == typeof(short) || type == typeof(byte) || type == typeof(long)) return "int"; + if (type == typeof(double) || type == typeof(float) || type == typeof(decimal)) return "double"; + return "string"; + } + + private static (object? Value, string Type) InferJsonValueAndType(string json) + { + if (string.IsNullOrWhiteSpace(json)) return (string.Empty, "string"); + + try + { + var unquoted = json.Trim('\"', ' '); + if (bool.TryParse(unquoted, out var b)) return (b, "bool"); + if (long.TryParse(unquoted, out var l)) return (l, "int"); + if (double.TryParse(unquoted, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) return (d, "double"); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + return root.ValueKind switch + { + JsonValueKind.True => (true, "bool"), + JsonValueKind.False => (false, "bool"), + JsonValueKind.Number when root.TryGetInt64(out var i) => (i, "int"), + JsonValueKind.Number => (root.GetDouble(), "double"), + JsonValueKind.String => (root.GetString(), "string"), + _ => (json, "string") + }; + } + catch + { + return (json, "string"); + } + } + + private static string FormatDescriptionFromKey(string key) + { + if (key.StartsWith("Logging.Channel.", StringComparison.OrdinalIgnoreCase)) + { + return $"Logging-Kanal für {key.Substring(16)} (Ein/Aus)"; + } + if (key.StartsWith("Feature.", StringComparison.OrdinalIgnoreCase)) + { + return $"Feature-Toggle für {key.Substring(8)}"; + } + if (key.StartsWith("Cache.", StringComparison.OrdinalIgnoreCase)) + { + return $"Cache-Konfiguration ({key.Substring(6)})"; + } + if (key.StartsWith("Scraper.", StringComparison.OrdinalIgnoreCase)) + { + return $"Scraper-Konfiguration ({key.Substring(8)})"; + } + return key; + } + #endregion } \ No newline at end of file diff --git a/FinlyticCore/Services/TradeRepublic/TradeRepublicClient.cs b/FinlyticCore/Services/TradeRepublic/TradeRepublicClient.cs index 7341053..9c333c2 100644 --- a/FinlyticCore/Services/TradeRepublic/TradeRepublicClient.cs +++ b/FinlyticCore/Services/TradeRepublic/TradeRepublicClient.cs @@ -4,8 +4,9 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos.TradeRepublic; +using FinlyticCore.Models.Settings; +using FinlyticCore.Services; using FinlyticCore.Util; -using Microsoft.Extensions.Logging; namespace FinlyticCore.Services.TradeRepublic; @@ -15,7 +16,7 @@ namespace FinlyticCore.Services.TradeRepublic; /// public class TradeRepublicClient : ManagedWebSocket { - private readonly ILogger _logger; + private readonly IFinlyticLogger _finlyticLogger; private int _currentSub; private readonly ConcurrentDictionary> _pendingRequests = new(); private readonly ConcurrentDictionary> _tickerSubscriptions = new(); @@ -26,10 +27,10 @@ public class TradeRepublicClient : ManagedWebSocket /// /// Initializes a new instance of the class. /// - /// The logger instance. - public TradeRepublicClient(ILogger logger) + /// The logger instance. + public TradeRepublicClient(IFinlyticLogger finlyticLogger) { - _logger = logger; + _finlyticLogger = finlyticLogger; } /// @@ -57,7 +58,7 @@ public class TradeRepublicClient : ManagedWebSocket var isConnected = res.Type == "connected"; if (isConnected) { - _logger.LogInformation("[{Channel}] WebSocket connection to Trade Republic established.", "TradeRepublicChannel"); + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicClient] WebSocket connection to Trade Republic established."); } return isConnected; @@ -65,7 +66,7 @@ public class TradeRepublicClient : ManagedWebSocket catch (Exception ex) { _pendingRequests.TryRemove(-1, out _); - _logger.LogWarning(ex, "[{Channel}] Failed or timed out establishing Trade Republic WebSocket connection.", "TradeRepublicChannel"); + await _finlyticLogger.LogWarningAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicClient] Failed or timed out establishing Trade Republic WebSocket connection."); return false; } } @@ -84,7 +85,7 @@ public class TradeRepublicClient : ManagedWebSocket var tempSub = Interlocked.Increment(ref _currentSub); var msg = $"sub {tempSub} {JsonSerializer.Serialize(request, typeof(TRequest), FinlyticJsonSerializerContext.Default)}"; - _logger.LogDebug("[{Channel}] TR WS Sent (Request): {Message}", "TradeRepublicChannel", msg); + await _finlyticLogger.LogDebugAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicClient] TR WS Sent (Request): {Message}", msg); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingRequests.TryAdd(tempSub, tcs); @@ -99,7 +100,7 @@ public class TradeRepublicClient : ManagedWebSocket } catch (Exception ex) { - _logger.LogWarning(ex, "[{Channel}] Error waiting for Trade Republic response ID {SubId}", "TradeRepublicChannel", tempSub); + await _finlyticLogger.LogWarningAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicClient] Error waiting for Trade Republic response ID {SubId}", tempSub); return null; } finally @@ -125,7 +126,6 @@ public class TradeRepublicClient : ManagedWebSocket _tickerSubscriptions[tempSub] = jsonPayload => { - // Skip empty or non-JSON payloads (e.g. TR protocol ack messages) if (string.IsNullOrWhiteSpace(jsonPayload) || (!jsonPayload.TrimStart().StartsWith('{') && !jsonPayload.TrimStart().StartsWith('['))) return; @@ -139,12 +139,12 @@ public class TradeRepublicClient : ManagedWebSocket } catch (Exception ex) { - _logger.LogWarning(ex, "[{Channel}] Failed to parse real-time ticker payload for {TickerId}", "TradeRepublicChannel", tickerId); + _ = _finlyticLogger.LogWarningAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicClient] Failed to parse real-time ticker payload for {TickerId}", tickerId); } }; - _logger.LogInformation("[{Channel}] Subscribing to Trade Republic real-time ticker {TickerId} (Sub ID: {SubId})", "TradeRepublicChannel", tickerId, tempSub); - _logger.LogDebug("[{Channel}] TR WS Sent: {Message}", "TradeRepublicChannel", msg); + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicClient] Subscribing to Trade Republic real-time ticker {TickerId} (Sub ID: {SubId})", tickerId, tempSub); + await _finlyticLogger.LogDebugAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicClient] TR WS Sent: {Message}", msg); await SendAsync(msg); return tempSub; } @@ -165,85 +165,81 @@ public class TradeRepublicClient : ManagedWebSocket } /// - protected override void OnMessageReceived(string message) -{ - if (string.IsNullOrWhiteSpace(message)) return; - - _logger.LogDebug("[{Channel}] TR WS Recv: {Message}", "TradeRepublicChannel", message); - - var trimmed = message.Trim(); - - int subId; - string type; - string payload; - - _logger.LogDebug("Trade republic response: " + message); - - if (trimmed.Equals("connected", StringComparison.OrdinalIgnoreCase)) + protected override void OnMessageReceived(string message) { - subId = -1; - type = "connected"; - payload = trimmed; - } - else - { - // Ziffern am Anfang zählen (Sub-ID) - var digitLen = 0; - while (digitLen < trimmed.Length && char.IsDigit(trimmed[digitLen])) - { - digitLen++; - } + if (string.IsNullOrWhiteSpace(message)) return; - // Keine Ziffer am Anfang (Reines System-Event/Error ohne ID) - if (digitLen == 0) - { - SystemMessageReceived?.Invoke(trimmed); - return; - } + _ = _finlyticLogger.LogDebugAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicClient] TR WS Recv: {Message}", message); - if (!int.TryParse(trimmed.Substring(0, digitLen), out subId)) - { - SystemMessageReceived?.Invoke(trimmed); - return; - } + var trimmed = message.Trim(); - var remainder = trimmed.Substring(digitLen).TrimStart(); + int subId; + string type; + string payload; - // 2. FALL: "34 connected" oder "34connected" - if (remainder.StartsWith("connected", StringComparison.OrdinalIgnoreCase)) + if (trimmed.Equals("connected", StringComparison.OrdinalIgnoreCase)) { - subId = -1; // Mapping auf deine interne -1 für InitAsync + subId = -1; type = "connected"; - payload = remainder; - } - else if (remainder.Length > 0) - { - // Standard Trade Republic Data Push (z.B. "22A {...}") - type = remainder[0].ToString(); - payload = remainder.Substring(1).TrimStart(); + payload = trimmed; } else { - type = "ack"; - payload = string.Empty; + // Ziffern am Anfang zählen (Sub-ID) + var digitLen = 0; + while (digitLen < trimmed.Length && char.IsDigit(trimmed[digitLen])) + { + digitLen++; + } + + // Keine Ziffer am Anfang (Reines System-Event/Error ohne ID) + if (digitLen == 0) + { + SystemMessageReceived?.Invoke(trimmed); + return; + } + + if (!int.TryParse(trimmed.Substring(0, digitLen), out subId)) + { + SystemMessageReceived?.Invoke(trimmed); + return; + } + + var remainder = trimmed.Substring(digitLen).TrimStart(); + + // 2. FALL: "34 connected" oder "34connected" + if (remainder.StartsWith("connected", StringComparison.OrdinalIgnoreCase)) + { + subId = -1; + type = "connected"; + payload = remainder; + } + else if (remainder.Length > 0) + { + type = remainder[0].ToString(); + payload = remainder.Substring(1).TrimStart(); + } + else + { + type = "ack"; + payload = string.Empty; + } } + + var received = new ReceivedMessage(subId, type, payload); + + if (_pendingRequests.TryGetValue(subId, out var tcs)) + { + tcs.TrySetResult(received); + } + + if (_tickerSubscriptions.TryGetValue(subId, out var handler)) + { + handler(payload); + } + + UnhandledMessageReceived?.Invoke(received); } - - var received = new ReceivedMessage(subId, type, payload); - - // Löst jetzt garantiert dein TaskCompletionSource(-1) in InitAsync auf! - if (_pendingRequests.TryGetValue(subId, out var tcs)) - { - tcs.TrySetResult(received); - } - - if (_tickerSubscriptions.TryGetValue(subId, out var handler)) - { - handler(payload); - } - - UnhandledMessageReceived?.Invoke(received); -} } /// diff --git a/FinlyticCore/Services/TradeRepublic/TradeRepublicService.cs b/FinlyticCore/Services/TradeRepublic/TradeRepublicService.cs index d47f6e0..6a70897 100644 --- a/FinlyticCore/Services/TradeRepublic/TradeRepublicService.cs +++ b/FinlyticCore/Services/TradeRepublic/TradeRepublicService.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using System.Timers; using FinlyticCore.Dtos.TradeRepublic; using FinlyticCore.Models.Assets; -using Microsoft.Extensions.Logging; +using FinlyticCore.Models.Settings; namespace FinlyticCore.Services.TradeRepublic; @@ -16,72 +16,50 @@ public interface ITradeRepublicService /// /// Fetches asset metadata from Trade Republic by ISIN. /// - /// The ISIN to search for. - /// A cancellation token. - /// The Trade Republic search response, or null if not found/failed. Task GetAsset(string isin, CancellationToken cancellationToken = default); /// /// Retrieves the total count of available assets grouped by their types. /// - /// A token to monitor for cancellation requests. - /// An object containing the metrics. Task GetAssetsCount(CancellationToken cancellationToken = default); /// /// Retrieves a paginated chunk of assets filtered by a specific type. /// - /// The type of assets to retrieve. - /// The zero-based page index. - /// The number of elements per page. - /// A token to monitor for cancellation requests. - /// A containing the elements, or null if the request fails. Task GetAssets(AssetType type, int page, int pageSize, CancellationToken cancellationToken = default); /// /// Subscribes to the real-time ticker stream for a specific ISIN. /// - /// The ISIN. - /// The callback action when a tick is received. - /// A cancellation token. - /// The subscription ID, or null if failed. Task SubscribeRealtimeTickerAsync(string isin, Action onTick, CancellationToken cancellationToken = default); /// /// Unsubscribes from a real-time ticker stream. /// - /// The subscription ID to unsubscribe. - /// A task representing the async operation. Task UnsubscribeRealtimeTickerAsync(int subId); /// /// Fetches stock details (company description, events, earnings, analyst ratings) for a specific ISIN. /// - /// The ISIN of the stock. - /// A cancellation token. - /// The stock details response, or null if failed. Task GetStockDetailsAsync(string isin, CancellationToken cancellationToken = default); /// /// Fetches derivative products (KnockOuts, Warrants, Factor Certificates) for an underlying ISIN. /// - /// The derivative query parameters. - /// A cancellation token. - /// The derivatives response, or null if failed. Task GetDerivativesAsync(TradeRepublicDerivativesRequest request, CancellationToken cancellationToken = default); } public class TradeRepublicService : ITradeRepublicService, IDisposable { private readonly TradeRepublicClient _client; - private readonly ILogger _logger; + private readonly IFinlyticLogger _finlyticLogger; private readonly System.Timers.Timer _inactivityTimer; private readonly SemaphoreSlim _lock = new(1, 1); - public TradeRepublicService(TradeRepublicClient client, ILogger logger) + public TradeRepublicService(TradeRepublicClient client, IFinlyticLogger finlyticLogger) { _client = client; - _logger = logger; + _finlyticLogger = finlyticLogger; _inactivityTimer = new System.Timers.Timer(TimeSpan.FromSeconds(461).TotalMilliseconds); _inactivityTimer.AutoReset = false; @@ -96,14 +74,14 @@ public class TradeRepublicService : ITradeRepublicService, IDisposable _inactivityTimer.Stop(); if (!_client.IsConnected) { - _logger.LogInformation("[{Channel}] Connecting to Trade Republic API WebSocket...", "TradeRepublicChannel"); + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicService] Connecting to Trade Republic API WebSocket..."); bool connected = await _client.InitAsync(); if (!connected) { - _logger.LogWarning("[{Channel}] Trade Republic WebSocket connection failed or timed out.", "TradeRepublicChannel"); + await _finlyticLogger.LogWarningAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicService] Trade Republic WebSocket connection failed or timed out."); throw new InvalidOperationException("Trade Republic WebSocket is not connected."); } - _logger.LogInformation("[{Channel}] Successfully connected to Trade Republic API.", "TradeRepublicChannel"); + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicService] Successfully connected to Trade Republic API."); } _inactivityTimer.Start(); } @@ -131,7 +109,7 @@ public class TradeRepublicService : ITradeRepublicService, IDisposable } catch (Exception ex) { - _logger.LogError(ex, "[{Channel}] Error while fetching asset metadata for ISIN {Isin}", "TradeRepublicChannel", isin); + await _finlyticLogger.LogErrorAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicService] Error while fetching asset metadata for ISIN {Isin}", isin); return null; } } @@ -206,7 +184,7 @@ public class TradeRepublicService : ITradeRepublicService, IDisposable } catch (Exception ex) { - _logger.LogError(ex, "[{Channel}] Error while fetching stock details for ISIN {Isin}", "TradeRepublicChannel", isin); + await _finlyticLogger.LogErrorAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicService] Error while fetching stock details for ISIN {Isin}", isin); return null; } } @@ -221,7 +199,7 @@ public class TradeRepublicService : ITradeRepublicService, IDisposable } catch (Exception ex) { - _logger.LogError(ex, "[{Channel}] Error while fetching derivatives for underlying {Underlying}", "TradeRepublicChannel", request.Underlying); + await _finlyticLogger.LogErrorAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicService] Error while fetching derivatives for underlying {Underlying}", request.Underlying); return null; } } @@ -232,7 +210,7 @@ public class TradeRepublicService : ITradeRepublicService, IDisposable { await _lock.WaitAsync(); if (!_client.IsConnected) return; - _logger.LogInformation("[{Channel}] Inactivity timer expired. Auto-disconnecting Trade Republic WebSocket.", "TradeRepublicChannel"); + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicService] Inactivity timer expired. Auto-disconnecting Trade Republic WebSocket."); await _client.DisconnectAsync(); } catch { } diff --git a/FinlyticFundamentals/Services/YahooFinanceScraper.cs b/FinlyticCore/Services/Yahoo/YahooFinanceScraper.cs similarity index 79% rename from FinlyticFundamentals/Services/YahooFinanceScraper.cs rename to FinlyticCore/Services/Yahoo/YahooFinanceScraper.cs index 7b2adb6..ae80248 100644 --- a/FinlyticFundamentals/Services/YahooFinanceScraper.cs +++ b/FinlyticCore/Services/Yahoo/YahooFinanceScraper.cs @@ -1,19 +1,17 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Clients; using FinlyticCore.Dtos.Fundamentals; using FinlyticCore.Dtos.Yahoo; +using FinlyticCore.Models.Settings; using FinlyticCore.Services; -using FinlyticCore.Services.Yahoo; -using FinlyticFundamentals.Database; -using FinlyticFundamentals.Util; -using Microsoft.Extensions.Logging; +using FinlyticCore.Utils; +using Microsoft.Extensions.Configuration; -namespace FinlyticFundamentals.Services; +namespace FinlyticCore.Services.Yahoo; public interface IYahooFinanceScraper { @@ -31,10 +29,6 @@ public interface IYahooFinanceScraper /// Ruft Fundamental- und Unternehmensdaten primär über die Yahoo Finance API ab /// und fällt automatisch auf den Playwright HTML Scraper zurück, falls keine Daten vorhanden sind. /// - /// Das Tickersymbol (z. B. "MSFT") oder die ISIN. - /// Erzwingt sofortiges HTML-Scraping ohne API-Vorprüfung. - /// Abbruch-Token. - /// Das aggregierte oder null. Task GetQuoteSummaryModulesAsync( string symbolOrIsin, bool forceHtmlScrape = false, @@ -43,16 +37,17 @@ public interface IYahooFinanceScraper public class YahooFinanceScraper : IYahooFinanceScraper { + private const string _serviceName = nameof(YahooFinanceScraper); private readonly YahooFinanceClient _yahooApiClient; private readonly IYahooFinanceHtmlClient _htmlScraperClient; - private readonly Microsoft.Extensions.Configuration.IConfiguration _configuration; - private readonly IFinlyticLogger _finlyticLogger; + private readonly IConfiguration _configuration; + private readonly IFinlyticLogger _finlyticLogger; public YahooFinanceScraper( YahooFinanceClient yahooApiClient, IYahooFinanceHtmlClient htmlScraperClient, - Microsoft.Extensions.Configuration.IConfiguration configuration, - IFinlyticLogger finlyticLogger) + IConfiguration configuration, + IFinlyticLogger finlyticLogger) { _yahooApiClient = yahooApiClient; _htmlScraperClient = htmlScraperClient; @@ -78,7 +73,7 @@ public class YahooFinanceScraper : IYahooFinanceScraper // Crypto / Trade Republic interne ISINs (beginnend mit 'X', z. B. XF000BTC0017) if (cleanIsin.StartsWith("X", StringComparison.OrdinalIgnoreCase)) { - var (cryptoSubtitle, cryptoName) = await FinlyticCore.Utils.CryptoSubtitleResolver.ResolveCryptoInfoAsync( + var (cryptoSubtitle, cryptoName) = await CryptoSubtitleResolver.ResolveCryptoInfoAsync( cleanIsin, _configuration.GetConnectionString("DefaultConnection"), cancellationToken); if (!string.IsNullOrWhiteSpace(cryptoSubtitle)) @@ -105,9 +100,8 @@ public class YahooFinanceScraper : IYahooFinanceScraper } catch { } - await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel, - "[YahooFinanceScraper] Resolved Crypto ISIN {Isin} to {Symbol} using Subtitle {Sub}", - cleanIsin, cryptoEur, cryptoSubtitle); + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.FundamentalsChannel, + $"[{_serviceName}] Resolved Crypto ISIN {cleanIsin} to {cryptoEur} using Subtitle {cryptoSubtitle}"); return symbols .OrderBy(s => s.priority) @@ -130,7 +124,6 @@ public class YahooFinanceScraper : IYahooFinanceScraper foreach (var q in validQuotes.Skip(1)) { - if (!symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase))) { symbols.Add((q.Symbol, q.Exchange ?? string.Empty, Math.Max(1, GetExchangePriority(q.Symbol, cleanIsin)))); @@ -158,13 +151,13 @@ public class YahooFinanceScraper : IYahooFinanceScraper } catch (Exception ex) { - await _finlyticLogger.LogWarningAsync(SettingKeys.YahooClientChannel, ex, - "[YahooFinanceScraper] Fehler beim Auflösen des Tickers für ISIN '{Isin}'", cleanIsin); + await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, ex, + $"[{_serviceName}] Fehler beim Auflösen des Tickers für ISIN '{cleanIsin}'"); } return symbols .OrderBy(s => s.priority) - .Select(s => new TickerInfoDto(){Ticker = s.symbol, Exchange = s.exchange}) + .Select(s => new TickerInfoDto { Ticker = s.symbol, Exchange = s.exchange }) .ToList(); } @@ -197,26 +190,26 @@ public class YahooFinanceScraper : IYahooFinanceScraper { try { - await _finlyticLogger.LogInfoAsync(SettingKeys.YahooClientChannel, - "[YahooFinanceScraper] Starte primären API-Abruf für '{Symbol}'...", symbol); + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, + $"[{_serviceName}] Starte primären API-Abruf für '{symbol}'..."); var apiResponse = await _yahooApiClient.GetFullQuoteSummaryAsync(symbol, cancellationToken); apiModules = apiResponse?.QuoteSummary?.Result?.FirstOrDefault(); if (apiModules != null && HasSufficientData(apiModules)) { - await _finlyticLogger.LogInfoAsync(SettingKeys.YahooClientChannel, - "[YahooFinanceScraper] Erfolgreich Daten über API bezogen für '{Symbol}'.", symbol); + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, + $"[{_serviceName}] Erfolgreich Daten über API bezogen für '{symbol}'."); return apiModules; } - await _finlyticLogger.LogWarningAsync(SettingKeys.YahooClientChannel, - "[YahooFinanceScraper] API lieferte unvollständige Daten für '{Symbol}'. Initiiere Fallback...", symbol); + await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, + $"[{_serviceName}] API lieferte unvollständige Daten für '{symbol}'. Initiiere Fallback..."); } catch (Exception ex) { - await _finlyticLogger.LogWarningAsync(SettingKeys.YahooClientChannel, ex, - "[YahooFinanceScraper] API-Abruf fehlgeschlagen für '{Symbol}'. Wechsle zu Scraper...", symbol); + await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, ex, + $"[{_serviceName}] API-Abruf fehlgeschlagen für '{symbol}'. Wechsle zu Scraper..."); } } @@ -226,15 +219,15 @@ public class YahooFinanceScraper : IYahooFinanceScraper YahooQuoteSummaryModulesDto? htmlModules = null; try { - await _finlyticLogger.LogInfoAsync(SettingKeys.YahooClientChannel, - "[YahooFinanceScraper] Starte HTML-Scraper Fallback für '{Symbol}'...", symbol); + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, + $"[{_serviceName}] Starte HTML-Scraper Fallback für '{symbol}'..."); htmlModules = await _htmlScraperClient.ScrapeQuoteSummaryModulesAsync(symbol, cancellationToken); } catch (Exception ex) { - await _finlyticLogger.LogErrorAsync(SettingKeys.YahooClientChannel, ex, - "[YahooFinanceScraper] HTML-Scraper Fallback ebenfalls fehlgeschlagen für '{Symbol}'.", symbol); + await _finlyticLogger.LogErrorAsync(CoreSettingKeys.YahooClientChannel, ex, + $"[{_serviceName}] HTML-Scraper Fallback ebenfalls fehlgeschlagen für '{symbol}'."); } // ------------------------------------------------------------- @@ -246,9 +239,6 @@ public class YahooFinanceScraper : IYahooFinanceScraper return MergeModules(apiModules, htmlModules); } - /// - /// Prüft, ob das Modul-DTO die wesentlichen Fundamentalblöcke enthält. - /// private static bool HasSufficientData(YahooQuoteSummaryModulesDto modules) { return modules.SummaryDetail != null || @@ -256,9 +246,6 @@ public class YahooFinanceScraper : IYahooFinanceScraper modules.DefaultKeyStatistics != null; } - /// - /// Führt API- und Scraper-Daten zusammen, damit Lücken in API-Responses geschlossen werden. - /// private static YahooQuoteSummaryModulesDto MergeModules( YahooQuoteSummaryModulesDto primary, YahooQuoteSummaryModulesDto secondary) @@ -311,4 +298,4 @@ public class YahooFinanceScraper : IYahooFinanceScraper return 10; } -} \ No newline at end of file +} diff --git a/FinlyticCore/Util/FinlyticJsonSerializerContext.cs b/FinlyticCore/Util/FinlyticJsonSerializerContext.cs index b450e24..bc99a00 100644 --- a/FinlyticCore/Util/FinlyticJsonSerializerContext.cs +++ b/FinlyticCore/Util/FinlyticJsonSerializerContext.cs @@ -18,6 +18,8 @@ namespace FinlyticCore.Util; DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(TradeProposalDto))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(FinlyticCore.Dtos.Logging.LogMessageDto))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(TradeAcceptanceDto))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(CloseTradeRequest))] diff --git a/FinlyticCore/Util/ManagedMqttClient.cs b/FinlyticCore/Util/ManagedMqttClient.cs index 91c3eb9..a2feec5 100644 --- a/FinlyticCore/Util/ManagedMqttClient.cs +++ b/FinlyticCore/Util/ManagedMqttClient.cs @@ -6,6 +6,8 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Models; +using FinlyticCore.Models.Settings; +using FinlyticCore.Services; using Microsoft.Extensions.Logging; using MQTTnet; @@ -14,10 +16,13 @@ namespace FinlyticCore.Util; /// /// An abstract, resilient MQTT client wrapper designed for microservice architectures. /// Handles automatic reconnection, structured JSON publishing, thread-safe subscription management, and synchronous Request-Reply (RPC). +/// Supports channel-controlled logging via . /// public abstract class ManagedMqttClient : IDisposable { private readonly ILogger _logger; + private readonly ISettingsService? _settingsService; + private readonly IFinlyticLogger? _finlyticLogger; private readonly IMqttClient _mqttClient; private CancellationTokenSource? _cts; @@ -29,15 +34,58 @@ public abstract class ManagedMqttClient : IDisposable /// public bool IsConnected => _mqttClient.IsConnected; - protected ManagedMqttClient(ILogger logger) + protected ManagedMqttClient( + ILogger logger, + ISettingsService? settingsService = null, + IFinlyticLogger? finlyticLogger = null) { _logger = logger; + _settingsService = settingsService; + _finlyticLogger = finlyticLogger; _mqttClient = new MqttClientFactory().CreateMqttClient(); _mqttClient.ApplicationMessageReceivedAsync += HandleIncomingMessageAsync; _mqttClient.DisconnectedAsync += HandleDisconnectAsync; } + private async Task LogMqttInfoAsync(string message, params object[] args) + { + if (_finlyticLogger != null) + { + await _finlyticLogger.LogInfoAsync(CoreSettingKeys.MqttChannel, message, args); + } + else if (_settingsService != null) + { + if (await _settingsService.GetSettingAsync(CoreSettingKeys.MqttChannel)) + { + _logger.LogInformation(message, args); + } + } + else + { + _logger.LogInformation(message, args); + } + } + + private async Task LogMqttDebugAsync(string message, params object[] args) + { + if (_finlyticLogger != null) + { + await _finlyticLogger.LogDebugAsync(CoreSettingKeys.MqttChannel, message, args); + } + else if (_settingsService != null) + { + if (await _settingsService.GetSettingAsync(CoreSettingKeys.MqttChannel)) + { + _logger.LogDebug(message, args); + } + } + else + { + _logger.LogDebug(message, args); + } + } + /// /// Establishes a connection to the MQTT broker and initializes the background auto-reconnection loop. /// @@ -61,12 +109,12 @@ public abstract class ManagedMqttClient : IDisposable var options = optionsBuilder.Build(); - _logger.LogInformation("Connecting to MQTT broker at {Host}:{Port}...", config.Host, config.Port); + await LogMqttInfoAsync("Connecting to MQTT broker at {Host}:{Port}...", config.Host, config.Port); try { await _mqttClient.ConnectAsync(options, _cts.Token); - _logger.LogInformation("Successfully connected to MQTT broker."); + await LogMqttInfoAsync("Successfully connected to MQTT broker."); await OnConnectedAsync(); } @@ -94,7 +142,7 @@ public abstract class ManagedMqttClient : IDisposable { Reason = MqttClientDisconnectOptionsReason.NormalDisconnection }); - _logger.LogInformation("MQTT connection gracefully closed."); + await LogMqttInfoAsync("MQTT connection gracefully closed."); } catch (Exception ex) { @@ -127,7 +175,7 @@ public abstract class ManagedMqttClient : IDisposable .Build(); await _mqttClient.SubscribeAsync(subscribeOptions, CancellationToken.None); - _logger.LogDebug("Successfully subscribed to topic: {Topic} (NoLocal: {NoLocal})", topic, noLocal); + await LogMqttDebugAsync("Successfully subscribed to topic: {Topic} (NoLocal: {NoLocal})", topic, noLocal); } /// @@ -180,15 +228,21 @@ public abstract class ManagedMqttClient : IDisposable return _mqttClient.PublishAsync(message, CancellationToken.None); } + /// + /// Sends a parameterless request to an RPC channel and asynchronously blocks until a matching response arrives. + /// + public Task SendRpcRequestAsync( + string channel, + TimeSpan? timeout = null) + where TResponse : class + { + return SendRpcRequestAsync(channel, string.Empty, timeout); + } + /// /// Sends a generic request payload to an RPC channel and asynchronously blocks until a matching response arrives. /// Uses the topic conventions: services/request/{channel}/{correlationId} and services/response/{channel}/{correlationId}. /// - /// The expected strongly-typed object type of the reply. - /// The type of the payload being transmitted. - /// The target sub-channel or service name (e.g., "sentix", "assets"). - /// The object that will be serialized to JSON and sent. - /// Optional. Maximum time to wait before returning null. Defaults to 10 seconds. public async Task SendRpcRequestAsync( string channel, TRequest requestData, @@ -209,7 +263,7 @@ public abstract class ManagedMqttClient : IDisposable // 2. Serialize and dispatch via the existing JSON helper await PublishAsync(requestTopic, requestData); - _logger.LogInformation("RPC request published to '{Topic}' [CorrelationId: {Id}]", requestTopic, correlationId); + await LogMqttInfoAsync("RPC request published to '{Topic}' [CorrelationId: {Id}]", requestTopic, correlationId); try { @@ -250,7 +304,7 @@ public abstract class ManagedMqttClient : IDisposable { var topic = e.ApplicationMessage.Topic; var payload = Encoding.UTF8.GetString(e.ApplicationMessage.Payload); - _logger.LogInformation("MQTT message received on topic '{Topic}', length={Length}", topic, payload?.Length ?? 0); + await LogMqttDebugAsync("MQTT message received on topic '{Topic}', length={Length}", topic, payload?.Length ?? 0); // Intercept message if it belongs to the RPC response convention if (topic.StartsWith("services/response/")) @@ -282,7 +336,6 @@ public abstract class ManagedMqttClient : IDisposable private async Task HandleDisconnectAsync(MqttClientDisconnectedEventArgs e) { - // Prevent trigger during deliberate connection shutdowns if (_cts == null || _cts.IsCancellationRequested) return; @@ -296,19 +349,19 @@ public abstract class ManagedMqttClient : IDisposable try { - _logger.LogInformation("Reconnect attempt {Attempt} in {Delay}s...", attempt, delaySeconds); + await LogMqttInfoAsync("Reconnect attempt {Attempt} in {Delay}s...", attempt, delaySeconds); await Task.Delay(TimeSpan.FromSeconds(delaySeconds), _cts.Token); await _mqttClient.ReconnectAsync(_cts.Token); if (_mqttClient.IsConnected) { - _logger.LogInformation("MQTT client reconnected successfully after {Attempt} attempt(s).", attempt); + await LogMqttInfoAsync("MQTT client reconnected successfully after {Attempt} attempt(s).", attempt); await OnConnectedAsync(); return; } } - catch (OperationCanceledException) { return; /* Expected on application shutdown */ } + catch (OperationCanceledException) { return; } catch (Exception ex) { _logger.LogWarning(ex, "Reconnect attempt {Attempt} to the MQTT broker failed.", attempt); @@ -318,15 +371,12 @@ public abstract class ManagedMqttClient : IDisposable /// /// Fired automatically whenever a connection or reconnection is successfully established. - /// Ideal place to trigger operations. /// protected abstract Task OnConnectedAsync(); /// /// Fired whenever a new message lands on a registered subscription channel. /// - /// The specific topic where the message was broadcasted. - /// The deserialized UTF-8 payload string. protected abstract Task OnMessageReceivedAsync(string topic, string payload); ///