feat(core): dynamic settings service, IFinlyticLogger, log broadcaster, and persistent Yahoo auth

This commit is contained in:
2026-08-15 21:29:38 +02:00
parent 34fa774cbf
commit 3dbee36ca0
15 changed files with 920 additions and 392 deletions
@@ -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;
/// </summary>
public class TradeRepublicClient : ManagedWebSocket
{
private readonly ILogger<TradeRepublicClient> _logger;
private readonly IFinlyticLogger<TradeRepublicClient> _finlyticLogger;
private int _currentSub;
private readonly ConcurrentDictionary<int, TaskCompletionSource<ReceivedMessage>> _pendingRequests = new();
private readonly ConcurrentDictionary<int, Action<string>> _tickerSubscriptions = new();
@@ -26,10 +27,10 @@ public class TradeRepublicClient : ManagedWebSocket
/// <summary>
/// Initializes a new instance of the <see cref="TradeRepublicClient"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
public TradeRepublicClient(ILogger<TradeRepublicClient> logger)
/// <param name="finlyticLogger">The logger instance.</param>
public TradeRepublicClient(IFinlyticLogger<TradeRepublicClient> finlyticLogger)
{
_logger = logger;
_finlyticLogger = finlyticLogger;
}
/// <summary>
@@ -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<ReceivedMessage>(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
}
/// <inheritdoc />
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);
}
}
/// <summary>