feat(backend): add admin evaluation history, engine, simulation, bot API controllers and global exception middleware
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user