R3 Slice 3: CongressTrading-Modul + Worker-Log + core_settings auf EF Core

- Modul: CongressTradingDbContext (ct_congressMember/ct_trade) + Design-Time-Factory;
  CongressRepository von Dapper auf EF; CongressTrade.DetailsFetched ergaenzt; Dapper entfernt
- Core: CoreSettingsService (core_settings via EF); WorkerBase-Log auf EF (core_worker_log);
  7 Worker-Ctors DatabaseService -> IDbContextFactory<CoreDbContext>
- CoreMigrations + CongressMigrations (Dapper) entfernt; core_-Schema nun rein EF
- EF-Migration InitialCongressTrading; AddCorePersistence-Fallback fuer leeren Connection-String
  (App startet ohne DB); Connection aus appsettings.Local.json (gitignored)
- Tests: +4 CongressRepository (EF-InMemory) -> 39/39 gruen; Build + smoke-ui + App-Start ok

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-28 11:34:42 +02:00
parent 7e3791b82d
commit 6c8e36dc3c
27 changed files with 832 additions and 323 deletions
@@ -1,91 +0,0 @@
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.Database.Migrations;
/// <summary>
/// Erstellt alle core_xxx-Tabellen idempotent (IF NOT EXISTS).
/// Wird einmalig beim App-Start ausgeführt.
/// </summary>
public class CoreMigrations
{
private readonly DatabaseService _db;
private readonly LoggingService _logger;
public CoreMigrations(DatabaseService db, LoggingService logger)
{
_db = db;
_logger = logger;
}
public async Task RunAsync()
{
_logger.Info("Core", "Starte Core-Datenbankmigrationen...");
await CreateCoreSettingsAsync();
await CreateCoreWorkerLogAsync();
await CreateCoreTradeHistoryAsync();
await CreateCoreBudgetAsync();
await CreateCorePositionAsync();
_logger.Info("Core", "Core-Migrationen abgeschlossen.");
}
// ─── Tabellen ─────────────────────────────────────────────────────────────
private Task CreateCoreSettingsAsync() => _db.ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS `core_settings` (
`key` VARCHAR(100) NOT NULL PRIMARY KEY,
`value` TEXT,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
private Task CreateCoreWorkerLogAsync() => _db.ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS `core_worker_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`worker_name` VARCHAR(100) NOT NULL,
`module` VARCHAR(50) NOT NULL DEFAULT 'Core',
`started_at` DATETIME,
`finished_at` DATETIME,
`status` ENUM('Running','Success','Error') DEFAULT 'Running',
`message` TEXT,
INDEX `idx_worker` (`worker_name`, `started_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
private Task CreateCoreTradeHistoryAsync() => _db.ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS `core_trade_history` (
`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`module` VARCHAR(50) NOT NULL,
`symbol` VARCHAR(20) NOT NULL,
`action` ENUM('BUY','SELL') NOT NULL,
`quantity` DECIMAL(18,4),
`price` DECIMAL(18,4),
`total_value` DECIMAL(18,4),
`traded_at` DATETIME,
`ibkr_order_id` VARCHAR(100),
`status` VARCHAR(50),
`notes` TEXT,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_symbol` (`symbol`),
INDEX `idx_module` (`module`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
private Task CreateCoreBudgetAsync() => _db.ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS `core_budget` (
`module` VARCHAR(50) NOT NULL PRIMARY KEY,
`total_budget` DECIMAL(18,2) DEFAULT 0,
`used_budget` DECIMAL(18,2) DEFAULT 0,
`max_per_trade` DECIMAL(18,2) DEFAULT 0,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
private Task CreateCorePositionAsync() => _db.ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS `core_position` (
`module` VARCHAR(50) NOT NULL,
`symbol` VARCHAR(20) NOT NULL,
`quantity` INT NOT NULL DEFAULT 0,
`avg_price` DECIMAL(18,4) NOT NULL DEFAULT 0,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`module`, `symbol`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
}
@@ -14,8 +14,18 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddCorePersistence(this IServiceCollection services, DatabaseOptions options)
{
services.AddDbContextFactory<CoreDbContext>(o =>
o.UseMySql(options.MySqlConnectionString, DatabaseServerVersion.Value));
o.UseMySql(EffectiveConnectionString(options.MySqlConnectionString), DatabaseServerVersion.Value));
return services;
}
/// <summary>
/// Liefert den Connection-String oder – bei leerem Wert (z. B. fehlende appsettings.Local.json) –
/// einen unschädlichen Platzhalter, damit der DbContext-Optionsbau nicht wirft. Die App startet so
/// unabhängig von der DB; echte Queries schlagen erst bei tatsächlichem Zugriff fehl.
/// </summary>
public static string EffectiveConnectionString(string? connectionString)
=> string.IsNullOrWhiteSpace(connectionString)
? "Server=localhost;Port=3306;Database=ibkrtrader;User ID=root;Password=;"
: connectionString;
}
@@ -0,0 +1,48 @@
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
namespace IBKRTrader.Core.Settings;
/// <summary>
/// Key-Value-Zugriff auf core_settings (EF Core). Von Core und Modulen für Fortschritts-/Flag-Werte
/// genutzt (z. B. "ct.history_import_page").
/// </summary>
public class CoreSettingsService
{
private readonly IDbContextFactory<CoreDbContext> _dbf;
public CoreSettingsService(IDbContextFactory<CoreDbContext> dbf) => _dbf = dbf;
public async Task<string?> GetAsync(string key)
{
await using var db = await _dbf.CreateDbContextAsync();
var s = await db.Settings.FindAsync(key);
return s?.Value;
}
public async Task SetAsync(string key, string value)
{
await using var db = await _dbf.CreateDbContextAsync();
var s = await db.Settings.FindAsync(key);
if (s is null)
db.Settings.Add(new CoreSetting { Key = key, Value = value, UpdatedAt = DateTime.UtcNow });
else
{
s.Value = value;
s.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
}
public async Task DeleteAsync(string key)
{
await using var db = await _dbf.CreateDbContextAsync();
var s = await db.Settings.FindAsync(key);
if (s is not null)
{
db.Settings.Remove(s);
await db.SaveChangesAsync();
}
}
}
@@ -1,3 +1,5 @@
using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
@@ -21,7 +23,7 @@ public class BackupWorker : WorkerBase
protected override TimeSpan? Interval =>
TimeSpan.FromMinutes(_settings.Settings.WorkerSettings.BackupWorker.IntervalMinutes);
public BackupWorker(LoggingService logger, DatabaseService db, SettingsService settings)
public BackupWorker(LoggingService logger, IDbContextFactory<CoreDbContext> db, SettingsService settings)
: base(logger, db)
{
_settings = settings;
@@ -1,3 +1,5 @@
using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.IBKR;
using IBKRTrader.Core.Logging;
@@ -22,7 +24,7 @@ public class IBKRInstrumentSyncWorker : WorkerBase
private readonly IBKRMarketDataRepository _repo;
public IBKRInstrumentSyncWorker(
LoggingService logger, DatabaseService db,
LoggingService logger, IDbContextFactory<CoreDbContext> db,
SettingsService settings, IBKRGatewayService gateway,
IBKRMarketDataRepository repo)
: base(logger, db)
@@ -1,3 +1,5 @@
using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.IBKR;
using IBKRTrader.Core.Logging;
@@ -29,7 +31,7 @@ public class IBKRPriceHistoryWorker : WorkerBase
private readonly IBKRMarketDataRepository _repo;
public IBKRPriceHistoryWorker(
LoggingService logger, DatabaseService db,
LoggingService logger, IDbContextFactory<CoreDbContext> db,
SettingsService settings, IBKRGatewayService gateway,
IBKRMarketDataRepository repo)
: base(logger, db)
@@ -1,3 +1,5 @@
using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Text;
using System.Text.Json;
@@ -27,7 +29,7 @@ public class WebApiService : WorkerBase
protected override TimeSpan? Interval => null;
public WebApiService(LoggingService logger, DatabaseService db, SettingsService settings)
public WebApiService(LoggingService logger, IDbContextFactory<CoreDbContext> db, SettingsService settings)
: base(logger, db)
{
_settings = settings;
@@ -1,3 +1,5 @@
using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Text;
using IBKRTrader.Core.Database;
@@ -22,7 +24,7 @@ public class WebserverService : WorkerBase
protected override TimeSpan? Interval => null; // Service = permanent
public WebserverService(LoggingService logger, DatabaseService db, SettingsService settings)
public WebserverService(LoggingService logger, IDbContextFactory<CoreDbContext> db, SettingsService settings)
: base(logger, db)
{
_settings = settings;
+32 -10
View File
@@ -1,5 +1,7 @@
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
namespace IBKRTrader.Core.Workers;
@@ -26,8 +28,8 @@ public abstract class WorkerBase : IWorker
// ─── Infrastruktur ────────────────────────────────────────────────────────
protected readonly LoggingService Logger;
protected readonly DatabaseService Db;
protected readonly LoggingService Logger;
private readonly IDbContextFactory<CoreDbContext> _dbf;
public WorkerInfo Info { get; } = new();
@@ -35,10 +37,10 @@ public abstract class WorkerBase : IWorker
private Task? _runLoop;
private readonly SemaphoreSlim _triggerSemaphore = new(0, 1);
protected WorkerBase(LoggingService logger, DatabaseService db)
protected WorkerBase(LoggingService logger, IDbContextFactory<CoreDbContext> dbf)
{
Logger = logger;
Db = db;
_dbf = dbf;
Info.WorkerName = Name;
Info.Module = Module;
@@ -153,13 +155,33 @@ public abstract class WorkerBase : IWorker
// ─── DB-Log-Seam (überschreibbar für Unit-Tests) ──────────────────────────
/// <summary>Legt den Worker-Log-Eintrag an. Kapselt den DB-Zugriff (testbar).</summary>
protected virtual Task<long> BeginRunLogAsync()
=> Db.BeginWorkerLogAsync(Name, Module);
/// <summary>Legt den Worker-Log-Eintrag an (core_worker_log via EF). Kapselt den DB-Zugriff (testbar).</summary>
protected virtual async Task<long> BeginRunLogAsync()
{
await using var db = await _dbf.CreateDbContextAsync();
var log = new CoreWorkerLog
{
WorkerName = Name,
Module = Module,
StartedAt = DateTime.UtcNow,
Status = "Running"
};
db.WorkerLog.Add(log);
await db.SaveChangesAsync();
return log.Id;
}
/// <summary>Schließt den Worker-Log-Eintrag ab. Kapselt den DB-Zugriff (testbar).</summary>
protected virtual Task EndRunLogAsync(long logId, bool success, string? message = null)
=> Db.EndWorkerLogAsync(logId, success, message);
protected virtual async Task EndRunLogAsync(long logId, bool success, string? message = null)
{
await using var db = await _dbf.CreateDbContextAsync();
var log = await db.WorkerLog.FindAsync(logId);
if (log is null) return;
log.FinishedAt = DateTime.UtcNow;
log.Status = success ? "Success" : "Error";
log.Message = message;
await db.SaveChangesAsync();
}
// ─── Hilfsmethoden ────────────────────────────────────────────────────────