Files
QuantEngineByItz/src/dotnet/QuantEngine.Web/Program.cs
T
kjh2064 6e666844f0
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 17s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
feat: add canonical decision learning service
2026-07-12 11:53:17 +09:00

213 lines
7.7 KiB
C#

using Microsoft.AspNetCore.DataProtection;
using QuantEngine.Infrastructure.Data;
using QuantEngine.Infrastructure.Repositories;
using QuantEngine.Infrastructure.Services;
using QuantEngine.Core.Interfaces;
using QuantEngine.Application.Services;
using QuantEngine.Application.Interfaces;
using Serilog;
using QuantEngine.Web.Services;
using Hangfire;
using Npgsql;
using FastEndpoints;
using FluentValidation;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console()
.WriteTo.File("logs/quantengine-.log", rollingInterval: RollingInterval.Day)
.CreateLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog();
// Data Protection: without this, ASP.NET Core derives its key-ring
// discriminator from the app's physical content root path. Every
// deployment lands in a brand-new directory
// (~/deployments/quantengine_{tag}_{hash}/), so the discriminator
// changed on every single deploy and every previously-issued auth
// cookie became undecryptable -- forcing all users to log in again
// after each release. SetApplicationName pins a stable discriminator;
// PersistKeysToFileSystem points at a location outside the versioned
// deployment folders so the actual key material also survives restarts.
var dataProtectionKeysPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"quantengine-keys");
builder.Services.AddDataProtection()
.SetApplicationName("QuantEngine")
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath));
// Authentication & Authorization
builder.Services.AddAuthentication(opts =>
{
opts.DefaultAuthenticateScheme = AdminAuthDefaults.Scheme;
opts.DefaultChallengeScheme = AdminAuthDefaults.Scheme;
})
.AddCookie(AdminAuthDefaults.Scheme, opts =>
{
opts.Cookie.Name = AdminAuthDefaults.CookieName;
opts.Cookie.HttpOnly = true;
opts.Cookie.SameSite = SameSiteMode.Lax;
opts.Cookie.SecurePolicy = builder.Environment.IsProduction() ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
opts.LoginPath = "/Account/Login";
opts.AccessDeniedPath = "/Account/AccessDenied";
opts.SlidingExpiration = true;
opts.ExpireTimeSpan = TimeSpan.FromHours(12);
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(AdminAuthDefaults.Scheme, policy =>
{
policy.RequireAuthenticatedUser();
});
});
builder.Services.AddAntiforgery();
// Razor Pages with authorization conventions
builder.Services.AddRazorPages(options =>
{
options.Conventions.AuthorizeFolder("/Admin", AdminAuthDefaults.Scheme);
options.Conventions.AllowAnonymousToPage("/Account/Login");
options.Conventions.AllowAnonymousToPage("/Account/AccessDenied");
});
// Authentication Services
builder.Services.AddScoped<IIpLockoutService, IpLockoutService>();
builder.Services.AddScoped<AuthService>();
// PostgreSQL Dapper Setup
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string 'DefaultConnection' is required.");
var configuredDatabase = new NpgsqlConnectionStringBuilder(connectionString).Database;
if (!string.Equals(configuredDatabase, "quantenginedb", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("QuantEngine must use the quantenginedb PostgreSQL database.");
}
var dataSource = NpgsqlDataSource.Create(connectionString);
builder.Services.AddSingleton(dataSource);
builder.Services.AddSingleton<IDbConnectionFactory>(new DbConnectionFactory(dataSource));
builder.Services.AddSingleton(sp => new DbMigrator(connectionString, sp.GetRequiredService<ILogger<DbMigrator>>()));
// Repository Services
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
builder.Services.AddScoped<INormalizedLearningStore, NormalizedLearningStore>();
builder.Services.AddScoped<DecisionLearningService>();
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
builder.Services.AddScoped<HistoryIngestionService>();
builder.Services.AddScoped<ICollectionRepository, CollectionRepository>();
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
builder.Services.AddHttpClient<IKisApiClient, KisApiClient>();
// Collection Pipeline Services
builder.Services.AddScoped<SourcePriorityResolver>();
builder.Services.AddScoped<PriceDataNormalizer>();
builder.Services.AddScoped<ICollectionOrchestrator, KisDataCollectionOrchestrator>();
// Hangfire Background Jobs
try
{
var hangfireConnectionString = builder.Configuration.GetConnectionString("HangfireConnection") ?? connectionString;
builder.Services.AddHangfireServices(hangfireConnectionString);
}
catch (Exception ex)
{
Log.Warning("Hangfire initialization failed: {Message}", ex.Message);
}
// FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<QuantEngine.Infrastructure.Repositories.WorkspaceRepository>();
// FastEndpoints (for API endpoints)
builder.Services.AddFastEndpoints();
var app = builder.Build();
app.UseSerilogRequestLogging();
app.UseFastEndpoints();
// Database migration on startup
using (var scope = app.Services.CreateScope())
{
var migrator = scope.ServiceProvider.GetRequiredService<DbMigrator>();
var workspaceRepo = scope.ServiceProvider.GetRequiredService<IWorkspaceRepository>();
var collectionRepo = scope.ServiceProvider.GetRequiredService<ICollectionRepository>();
var tokenCache = scope.ServiceProvider.GetRequiredService<ITokenCache>();
try
{
migrator.Migrate();
await workspaceRepo.GetAccountsAsync();
await collectionRepo.GetDashboardStateAsync();
await tokenCache.GetCachedTokenAsync("_init_test_");
Log.Information("Database migration and initialization successful");
}
catch (Exception ex)
{
if (!app.Environment.IsDevelopment())
throw;
Log.Warning("Database initialization warning (development only): {Message}", ex.Message);
}
}
// Error handling & HSTS
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAntiforgery();
app.UseAuthentication();
app.UseAuthorization();
// Hangfire Dashboard
try
{
app.UseHangfireSetup(app.Services);
}
catch (Exception ex)
{
Log.Warning("Hangfire setup failed: {Message}", ex.Message);
}
// Root redirect: unauthenticated → /Account/Login, authenticated → /Admin/Dashboard
app.MapGet("/", context =>
{
if (context.User?.Identity?.IsAuthenticated ?? false)
context.Response.Redirect("/Admin/Dashboard");
else
context.Response.Redirect("/Account/Login");
return Task.CompletedTask;
});
// Login redirect convenience route
app.MapGet("/login", context =>
{
context.Response.Redirect("/Account/Login", permanent: false);
return Task.CompletedTask;
});
app.MapRazorPages();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
throw;
}
finally
{
Log.CloseAndFlush();
}