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
+73 -11
View File
@@ -4,10 +4,15 @@ using System.Threading.Tasks;
using FinlyticBackend.Controllers;
using FinlyticBackend.Database;
using FinlyticBackend.Hubs;
using FinlyticBackend.Middleware;
using FinlyticBackend.Services;
using FinlyticBackend.Util;
using FinlyticCore.Database;
using FinlyticCore.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -21,6 +26,12 @@ var builder = WebApplication.CreateBuilder(args);
// 1. Add Controllers
builder.Services.AddControllers();
// 1b. Standardized error responses (Rules.md §11): every unhandled exception becomes an RFC 7807
// Problem Details response via GlobalExceptionHandler instead of a raw stack trace or an anonymous
// error object hand-rolled per controller.
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
// 2. Define CORS Policy
builder.Services.AddCors(options =>
{
@@ -34,10 +45,28 @@ builder.Services.AddCors(options =>
});
// 3. Configure JWT Authentication
var secretKey = builder.Configuration["JWT:SecretKey"] ?? "FinlyticEnterpriseUltraSecureJwtSecretKey_2026_AtLeast32Chars!";
// Fail-fast: a Gateway that falls back to a repo-public signing key is worse than one that refuses to
// start (Rules.md §12). JwtTokenService applies the same guard independently for defense in depth.
var secretKey = builder.Configuration["JWT:SecretKey"] ?? builder.Configuration["JWT__SecretKey"];
if (string.IsNullOrWhiteSpace(secretKey) || secretKey.Length < 32)
{
throw new InvalidOperationException(
"JWT:SecretKey (bzw. JWT__SecretKey) ist nicht konfiguriert oder kürzer als 32 Zeichen (256 Bit). " +
"FinlyticBackend startet nicht ohne einen ausreichend starken, explizit konfigurierten Signing-Key.");
}
var issuer = builder.Configuration["JWT:Issuer"] ?? "FinlyticBackend";
var audience = builder.Configuration["JWT:Audience"] ?? "FinlyticClients";
// Fail-fast: the default Admin account must never be seeded with a hardcoded, repo-public password
// (Rules.md §12). UserService.SeedDefaultAdminAsync applies the same guard for defense in depth.
var adminDefaultPassword = builder.Configuration["ADMIN:DefaultPassword"] ?? builder.Configuration["ADMIN__DefaultPassword"];
if (string.IsNullOrWhiteSpace(adminDefaultPassword))
{
throw new InvalidOperationException(
"ADMIN:DefaultPassword (bzw. ADMIN__DefaultPassword) ist nicht konfiguriert. " +
"FinlyticBackend startet nicht ohne ein explizit konfiguriertes Initial-Passwort für den Default-Admin-Account.");
}
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
@@ -80,6 +109,11 @@ builder.Services.AddSignalR();
// 4. Register DB Context & Services
builder.Services.AddDbContext<BackendDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<BackendDbContext>());
// 4b. Dynamic Settings & Channel-Based Logging (previously absent for FinlyticBackend - see BackendSettingKeys).
builder.Services.AddSingleton<ISettingsService, SettingsService>();
builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
builder.Services.AddHttpClient<UserTradesController>();
builder.Services.AddHttpClient<UserFavoritesController>();
@@ -91,14 +125,34 @@ builder.Services.AddSingleton<IJwtTokenService, JwtTokenService>();
builder.Services.AddSingleton<IUserService, UserService>();
builder.Services.AddSingleton<IFirebaseNotificationService, FirebaseNotificationService>();
builder.Services.AddSingleton<BackendMqttBridge>();
builder.Services.AddHostedService<BackendMqttBridge>(sp => sp.GetRequiredService<BackendMqttBridge>());
builder.Services.AddSingleton<WebMqttClient>();
builder.Services.AddHostedService<WebMqttClient>(sp => sp.GetRequiredService<WebMqttClient>());
builder.Services.AddHostedService<BackendMqttBridge>();
builder.Services.AddHostedService<SystemHealthBackgroundService>();
builder.Services.AddHostedService<FavoritesPriceBackgroundService>();
var app = builder.Build();
// 0. Global exception -> Problem Details handler (Rules.md §11). Placed first so it wraps every
// downstream stage (static files, routing, CORS, auth, endpoints): on an unhandled exception it re-invokes
// the pipeline starting immediately after itself for the generated error response, so UseCors below still
// runs and still attaches CORS headers to the error response. This does NOT change the CORS-critical
// ordering documented below - UseRouting/UseCors/UseAuthentication/UseAuthorization still execute in the
// same relative order for both the happy path and the error path.
app.UseExceptionHandler();
// 0b. Trust X-Forwarded-* from the nginx reverse proxy in front of this service, so Request.Scheme/
// Request.Host reflect the public domain (e.g. finlytic.kleidukos.me) instead of the raw loopback
// connection nginx makes to Kestrel (localhost:5000). Without this, any code building an absolute URL
// from Request.Host/Scheme silently produces http://localhost:5000/... links. KnownProxies/KnownNetworks
// are cleared because nginx runs as a sidecar on an address Kestrel can't predict (Docker bridge network).
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost,
KnownNetworks = { },
KnownProxies = { }
});
// ----------------------------------------------------------------------
// MIDDLEWARE PIPELINE ORDER IS CRITICAL FOR CORS
// ----------------------------------------------------------------------
@@ -155,12 +209,13 @@ using (var scope = app.Services.CreateScope())
try
{
var context = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
await context.Database.MigrateAsync();
var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
await context.MigrateWithBootstrapAsync(connStr);
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
string adminDefaultPassword = builder.Configuration["ADMIN:DefaultPassword"] ?? "AdminDefaultPassword2026!";
await userService.SeedDefaultAdminAsync(adminDefaultPassword);
}
catch (Exception ex)
{
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
@@ -171,11 +226,7 @@ using (var scope = app.Services.CreateScope())
// Map Endpoints
app.MapControllers();
app.MapHub<TradeRealtimeHub>("/hubs/trades", options =>
{
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
});
app.MapHub<TradeHub>("/hubs/trade-updates", options =>
app.MapHub<TradeStreamHub>("/hubs/trade-stream", options =>
{
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
});
@@ -196,8 +247,19 @@ app.MapHub<LogStreamHub>("/hubs/logs", options =>
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
});
app.MapGet("/health", () => Results.Ok(new { status = "Healthy", service = "FinlyticBackend", timestamp = DateTime.UtcNow }));
// Explicit, sanctioned exception to "every route requires authentication" (Rules.md §7): the Docker
// healthcheck defined in compose.yaml sends an unauthenticated `GET /health` against 127.0.0.1:8080 and
// only checks for a 200 status code. A healthcheck cannot carry a bearer token/secret, so requiring auth
// here would make every container report unhealthy. The response body is limited to {status, service,
// timestamp} — no business or user data — so the anonymous surface stays minimal.
app.MapGet("/health", () => Results.Ok(new { status = "Healthy", service = "FinlyticBackend", timestamp = DateTime.UtcNow }))
.AllowAnonymous();
// Explicit, sanctioned exception to "every route requires authentication" (Rules.md §7): this serves the
// compiled Flutter Web SPA shell (index.html) for any unmatched route. It must stay anonymous, or the
// browser could never load the login page in the first place - the SPA itself enforces auth client-side
// once loaded, and every actual data-bearing API route below requires a JWT.
app.MapFallbackToFile("index.html");
await app.RunAsync();