feat(backend): add admin evaluation history, engine, simulation, bot API controllers and global exception middleware

This commit is contained in:
2026-08-24 21:37:32 +02:00
parent 6974b2075b
commit 676496b77d
37 changed files with 88203 additions and 83075 deletions
@@ -33,7 +33,7 @@ public class FavoritesPriceBackgroundService(
logger.LogWarning(ex, "[FavoritesPriceBackgroundService] Error broadcasting price updates.");
}
await Task.Delay(10000, stoppingToken);
await Task.Delay(2000, stoppingToken);
}
}
@@ -50,24 +50,23 @@ public class FavoritesPriceBackgroundService(
if (userFavorites.Count == 0) return;
// 2. Pro User einfach die Kurse abfragen und senden
// 2. Pro User einfach die Kurse parallel abfragen und senden
foreach (var userGroup in userFavorites.GroupBy(f => f.UserId.ToString()))
{
var userId = userGroup.Key;
var priceUpdates = new Dictionary<string, object>();
var priceUpdates = new System.Collections.Concurrent.ConcurrentDictionary<string, object>();
foreach (var fav in userGroup)
var tasks = userGroup.Select(async fav =>
{
var cleanIsin = fav.Isin.Trim().ToUpperInvariant();
if (!mqttClient.IsConnected) continue;
if (!mqttClient.IsConnected) return;
try
{
var livePrice = await mqttClient.SendRpcRequestAsync<LivePriceDto, IsinRequest>(
"tr_GetLivePrice",
new IsinRequest(cleanIsin),
TimeSpan.FromSeconds(2)
TimeSpan.FromSeconds(4)
);
if (livePrice != null)
@@ -80,12 +79,14 @@ public class FavoritesPriceBackgroundService(
}
}
catch { /* Ignorieren bei Einzel-Timeout */ }
}
});
if (priceUpdates.Count > 0)
await Task.WhenAll(tasks);
if (!priceUpdates.IsEmpty)
{
await hubContext.Clients.Group(FavoritesPriceHub.GetGroupName(userId))
.SendAsync("ReceiveFavoritePrices", priceUpdates, cancellationToken);
.SendAsync("ReceiveFavoritePrices", new Dictionary<string, object>(priceUpdates), cancellationToken);
}
}
}
@@ -4,7 +4,7 @@ using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Models.Trades;
using FinlyticCore.Dtos.Trading;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Services;
@@ -17,20 +17,12 @@ public interface IFirebaseNotificationService
/// <summary>
/// Sends a push notification about a new trade proposal.
/// </summary>
/// <param name="proposal">The trade proposal details.</param>
/// <param name="fcmTokens">The list of FCM device tokens.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task SendTradeProposalNotificationAsync(TradeProposalDto proposal, List<string> fcmTokens, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a push notification about an update to an existing trade.
/// </summary>
/// <param name="update">The trade update details.</param>
/// <param name="fcmTokens">The list of FCM device tokens.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task SendTradeUpdateNotificationAsync(TradeHourlyUpdateDto update, List<string> fcmTokens, CancellationToken cancellationToken = default);
Task SendTradeUpdateNotificationAsync(ActiveTradeDto update, List<string> fcmTokens, CancellationToken cancellationToken = default);
}
/// <inheritdoc />
@@ -39,11 +31,6 @@ public class FirebaseNotificationService : IFirebaseNotificationService
private readonly HttpClient _httpClient;
private readonly ILogger<FirebaseNotificationService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FirebaseNotificationService"/> class.
/// </summary>
/// <param name="httpClient">The HTTP client for making API requests.</param>
/// <param name="logger">The logger instance.</param>
public FirebaseNotificationService(HttpClient httpClient, ILogger<FirebaseNotificationService> logger)
{
_httpClient = httpClient;
@@ -51,13 +38,12 @@ public class FirebaseNotificationService : IFirebaseNotificationService
}
/// <inheritdoc />
public async Task SendTradeProposalNotificationAsync(TradeProposalDto proposal, List<string> fcmTokens, CancellationToken cancellationToken = default)
{
if (fcmTokens == null || fcmTokens.Count == 0) return;
string title = $"🚀 Trade Signal: {proposal.SignalType} {proposal.Symbol}";
string body = $"{proposal.CompanyName} ({proposal.Isin}) - Entry: ${proposal.EntryPrice:F2}, WinRate: {proposal.WinRate:F1}%. {proposal.Reasoning}";
string title = $"🚀 Trade Signal: {proposal.Direction} {proposal.Symbol} ({proposal.StrategyKey})";
string body = $"{proposal.Symbol} ({proposal.UnderlyingIsin}) - Entry: {proposal.EntryPrice:F2}, Score: {proposal.CompositeScore:F0} Pkt. {proposal.AiValidation?.ThesisSummary}";
foreach (var token in fcmTokens)
{
@@ -66,12 +52,12 @@ public class FirebaseNotificationService : IFirebaseNotificationService
}
/// <inheritdoc />
public async Task SendTradeUpdateNotificationAsync(TradeHourlyUpdateDto update, List<string> fcmTokens, CancellationToken cancellationToken = default)
public async Task SendTradeUpdateNotificationAsync(ActiveTradeDto update, List<string> fcmTokens, CancellationToken cancellationToken = default)
{
if (fcmTokens == null || fcmTokens.Count == 0) return;
string title = $"📊 Trade Update: {update.TradeId}";
string body = $"Recommendation: {update.Recommendation} @ ${update.CurrentPrice:F2}. {update.Reasoning}";
string title = $"📊 Trade Update: {update.Symbol} (Status: {update.Status})";
string body = $"Status: {update.Status} @ {update.CurrentPrice:F2}, PnL: {update.UnrealizedPnlPercent:+0.0;-0.0}% (€{update.UnrealizedPnlEur:+0.00;-0.00}).";
foreach (var token in fcmTokens)
{
+9 -7
View File
@@ -37,17 +37,19 @@ public class JwtTokenService : IJwtTokenService
public JwtTokenService(IConfiguration configuration)
{
var configuredKey = configuration["JWT:SecretKey"] ?? configuration["JWT__SecretKey"];
// Guard: Mindestlänge für HMAC-SHA256 erzwingen (mindestens 32 Zeichen / 256 Bits)
// Guard: Mindestlänge für HMAC-SHA256 erzwingen (mindestens 32 Zeichen / 256 Bits).
// Fail-fast statt Fallback auf einen im Repo öffentlichen Literal (Rules.md §12): ein Gateway,
// das mit einem repo-öffentlichen Signing-Key läuft, ist schlimmer als eines, das nicht startet.
if (string.IsNullOrWhiteSpace(configuredKey) || configuredKey.Length < 32)
{
_secretKey = "FinlyticEnterpriseUltraSecureJwtSecretKey_2026_AtLeast32Chars!";
}
else
{
_secretKey = configuredKey;
throw new InvalidOperationException(
"JWT:SecretKey (bzw. JWT__SecretKey) ist nicht konfiguriert oder kürzer als 32 Zeichen (256 Bit). " +
"JwtTokenService kann ohne einen ausreichend starken, explizit konfigurierten Signing-Key nicht initialisiert werden.");
}
_secretKey = configuredKey;
_issuer = configuration["JWT:Issuer"] ?? configuration["JWT__Issuer"] ?? "FinlyticBackend";
_audience = configuration["JWT:Audience"] ?? configuration["JWT__Audience"] ?? "FinlyticClients";
_expiryDays = int.TryParse(configuration["JWT:ExpiryDays"] ?? configuration["JWT__ExpiryDays"], out var days) ? days : 7;
@@ -65,11 +65,11 @@ public class SystemHealthBackgroundService : BackgroundService
{
("FinlyticAssets", "health_Ping/FinlyticAssets", "Asset Catalog & Scraper", "PostgreSQL assets"),
("FinlyticNews", "health_Ping/FinlyticNews", "News RSS Scraper & AI", "PostgreSQL news"),
("FinlyticTechnicalAnalysis", "health_Ping/FinlyticTechnicalAnalysis", "Technical Indicators (EMA/RSI)", "PostgreSQL ta"),
("FinlyticTechnicals", "health_Ping/FinlyticTechnicals", "Technical Indicators & SMC Patterns", "PostgreSQL ta"),
("FinlyticSentiment", "health_Ping/FinlyticSentiment", "NLP Sentiment Engine", "PostgreSQL sentiment"),
("FinlyticAnalyzer", "health_Ping/FinlyticAnalyzer", "Multi-Layer Signal Engine", "PostgreSQL analyzer"),
("FinlyticTrades", "health_Ping/FinlyticTrades", "Trade Lifecycle Manager", "PostgreSQL trades"),
("FinlyticFundamentals", "health_Ping/FinlyticFundamentals", "Financial Statements & Estimates", "PostgreSQL fundamentals"),
("FinlyticEngine", "health_Ping/FinlyticEngine", "Strategy Screener & Signals", "PostgreSQL engine"),
("FinlyticBot", "health_Ping/FinlyticBot", "Automated Trading Execution", "PostgreSQL bot"),
};
var results = new List<ServiceHealthStatusDto>
+12 -55
View File
@@ -21,9 +21,6 @@ public interface IUserService
/// <summary>Authenticates user credentials and returns JWT response.</summary>
Task<AuthResponseDto?> AuthenticateAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
/// <summary>Registers a new user account.</summary>
Task<AuthResponseDto?> RegisterUserAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
/// <summary>Changes the initial password for a user.</summary>
Task<bool> ChangeInitialPasswordAsync(Guid userId, string newPassword,
CancellationToken cancellationToken = default);
@@ -115,54 +112,6 @@ public class UserService : IUserService
};
}
/// <inheritdoc />
public async Task<AuthResponseDto?> RegisterUserAsync(RegisterRequestDto request,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
string normalizedEmail = request.Email.Trim().ToLowerInvariant();
bool exists = await dbContext.Users.AnyAsync(u => u.Email.ToLower() == normalizedEmail, cancellationToken);
if (exists)
{
_logger.LogWarning("[{Channel}] Registration failed: Email '{Email}' is already taken.", "AuthChannel",
request.Email);
return null;
}
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
var newUser = new UserEntity
{
Email = normalizedEmail,
PasswordHash = passwordHash,
FullName = string.IsNullOrWhiteSpace(request.FullName) ? normalizedEmail.Split('@')[0] : request.FullName,
Role = "User",
ThemePreference = "fluent_dark", // Default Theme for FluentAvalonia
IsActive = true,
CreatedAt = DateTime.UtcNow,
LastLoginAt = DateTime.UtcNow
};
dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync(cancellationToken);
var (token, expiresAt) = _jwtTokenService.GenerateToken(newUser);
return new AuthResponseDto
{
Token = token,
UserId = newUser.Id,
Email = newUser.Email,
FullName = newUser.FullName,
Role = newUser.Role,
ThemePreference = newUser.ThemePreference,
FcmTokens = new List<string>(),
ExpiresAt = expiresAt
};
}
/// <inheritdoc />
public async Task<UserDto?> CreateUserByAdminAsync(CreateUserRequestDto request,
CancellationToken cancellationToken = default)
@@ -352,13 +301,21 @@ public class UserService : IUserService
if (!exists)
{
string password = !string.IsNullOrWhiteSpace(defaultAdminPassword)
? defaultAdminPassword
: "AdminDefaultPassword2026!";
// Fail-fast statt Fallback auf einen im Repo öffentlichen Literal (Rules.md §12): ein Gateway,
// das den Default-Admin mit einem repo-öffentlichen Passwort anlegt, ist schlimmer als eines,
// das nicht startet. Program.cs validiert dies bereits vor dem Aufruf; dieser Guard dient als
// Verteidigung in der Tiefe, falls die Methode von anderer Stelle aufgerufen wird.
if (string.IsNullOrWhiteSpace(defaultAdminPassword))
{
throw new InvalidOperationException(
"ADMIN:DefaultPassword (bzw. ADMIN__DefaultPassword) ist nicht konfiguriert. " +
"Das Seeding des Default-Admin-Accounts kann ohne explizit konfiguriertes Passwort nicht durchgeführt werden.");
}
var adminUser = new UserEntity
{
Email = adminEmail,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
PasswordHash = BCrypt.Net.BCrypt.HashPassword(defaultAdminPassword),
FullName = "System Administrator",
Role = "Admin",
ThemePreference = "fluent_dark",