diff --git a/src/PolyTrader.Web/Endpoints/CopyTradingEndpoints.cs b/src/PolyTrader.Web/Endpoints/CopyTradingEndpoints.cs index 905e235..a530f37 100644 --- a/src/PolyTrader.Web/Endpoints/CopyTradingEndpoints.cs +++ b/src/PolyTrader.Web/Endpoints/CopyTradingEndpoints.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using PolyTrader.Modules.CopyTrading.Persistence; using PolyTraderSharp; using PolyTraderSharp.Models; +using PolyTraderSharp.Services; namespace PolyTrader.Web.Endpoints { @@ -60,7 +61,7 @@ namespace PolyTrader.Web.Endpoints }); // 2. Master-Trader anlegen / aktualisieren - group.MapPost("/traders", (IServiceProvider sp, CopyTradingState copyState, TrackedTraderInput input) => + group.MapPost("/traders", (IServiceProvider sp, CopyTradingState copyState, TerminalLogger logger, TrackedTraderInput input) => { var repo = sp.GetService(); @@ -109,12 +110,11 @@ namespace PolyTrader.Web.Endpoints trader.CopyTradeCount30d = existingTrader.CopyTradeCount30d; } - if (repo != null) - { - try { repo.Upsert(trader); } - catch { /* Fallback in-memory */ } - } + var failed = PersistenceGuard.Write(repo != null, () => repo!.Upsert(trader), logger, + $"Master-Trader #{id} ({trader.DisplayName})"); + if (failed != null) return failed; + // Erst nach erfolgreichem Schreiben in den Hauptspeicher (#534). copyState.Traders[id] = trader; return Results.Ok(new @@ -138,14 +138,13 @@ namespace PolyTrader.Web.Endpoints }); // 3. Master-Trader löschen - group.MapDelete("/traders/{id:int}", (IServiceProvider sp, CopyTradingState copyState, int id) => + group.MapDelete("/traders/{id:int}", (IServiceProvider sp, CopyTradingState copyState, TerminalLogger logger, int id) => { var repo = sp.GetService(); - if (repo != null) - { - try { repo.Delete(id); } - catch { } - } + + var failed = PersistenceGuard.Write(repo != null, () => repo!.Delete(id), logger, + $"Loeschen von Master-Trader #{id}"); + if (failed != null) return failed; copyState.Traders.TryRemove(id, out _); return Results.Ok(new { success = true, id }); @@ -334,17 +333,17 @@ namespace PolyTrader.Web.Endpoints group.MapPost("/settings/{accountId:int}", ( IServiceProvider sp, CopyTradingState copyState, + TerminalLogger logger, int accountId, CopyTradingAccountSettings input) => { input.AccountId = accountId; var repo = sp.GetService(); - if (repo != null) - { - try { repo.Upsert(input); } - catch { } - } + + var failed = PersistenceGuard.Write(repo != null, () => repo!.Upsert(input), logger, + $"CopyTrading-Einstellungen fuer Konto {accountId}"); + if (failed != null) return failed; copyState.AccountSettings[accountId] = input; diff --git a/src/PolyTrader.Web/Endpoints/PersistenceGuard.cs b/src/PolyTrader.Web/Endpoints/PersistenceGuard.cs new file mode 100644 index 0000000..ffced47 --- /dev/null +++ b/src/PolyTrader.Web/Endpoints/PersistenceGuard.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.AspNetCore.Http; +using PolyTraderSharp.Services; + +namespace PolyTrader.Web.Endpoints +{ + /// + /// Gemeinsamer Schreibweg für die Endpunkte, die etwas dauerhaft ablegen (#534). + /// + /// Erst schreiben, dann übernehmen. Vorher aktualisierte jeder Endpunkt den + /// Hauptspeicher auch dann, wenn die Datenbank abgelehnt hatte, und meldete trotzdem Erfolg. + /// Bei Risikolimits ist das der teuerste Zustand: Das Modul handelt mit Werten, die nirgends + /// stehen, und nach dem nächsten Neustart kann niemand mehr rekonstruieren, womit es gehandelt + /// hat. Ein sichtbar abgelehnter Speichervorgang ist ärgerlich und folgenlos — ein halb + /// angewandter ist unsichtbar und folgenreich. + /// + /// Dass alle vier Schreibwege denselben Helfer benutzen, ist der eigentliche Punkt: Aus je + /// eigenem try/catch je Endpunkt ist die Ungleichheit überhaupt erst entstanden. + /// + internal static class PersistenceGuard + { + /// + /// Führt aus. Gibt null zurück, wenn es gelang — dann darf der + /// Aufrufer den Hauptspeicher nachziehen. Sonst die fertige Fehlerantwort, die unverändert + /// zurückzugeben ist; der Hauptspeicher bleibt dabei unberührt. + /// + /// + /// Ob überhaupt eine Ablage vorhanden ist. Fehlt sie, ist das Modul nicht vollständig geladen — + /// eine Änderung wäre rein flüchtig und wird deshalb ebenso abgelehnt. + /// + public static IResult? Write(bool repositoryPresent, Action write, TerminalLogger logger, string what) + { + if (!repositoryPresent) + { + logger.Error($"{what}: keine Ablage verfügbar – nicht gespeichert."); + return Results.Problem( + detail: $"{what} konnte nicht gespeichert werden: Die Ablage ist nicht verfügbar. " + + "Es wurde nichts geändert.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + + try + { + write(); + return null; + } + catch (Exception ex) + { + logger.Error($"{what}: nicht gespeichert – {ex.Message}"); + return Results.Problem( + detail: $"{what} konnte nicht gespeichert werden: {ex.Message} Es wurde nichts geändert.", + statusCode: StatusCodes.Status500InternalServerError); + } + } + } +} diff --git a/src/PolyTrader.Web/Endpoints/ResolutionFarmingEndpoints.cs b/src/PolyTrader.Web/Endpoints/ResolutionFarmingEndpoints.cs index 4e33ae5..9b29092 100644 --- a/src/PolyTrader.Web/Endpoints/ResolutionFarmingEndpoints.cs +++ b/src/PolyTrader.Web/Endpoints/ResolutionFarmingEndpoints.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using PolyTrader.Modules.ResolutionFarming.Models; using PolyTrader.Modules.ResolutionFarming.Persistence; using PolyTraderSharp; +using PolyTraderSharp.Services; namespace PolyTrader.Web.Endpoints { @@ -217,16 +218,15 @@ namespace PolyTrader.Web.Endpoints }); // 5. Einstellungen speichern - group.MapPost("/settings/{accountId:int}", (IServiceProvider sp, int accountId, RfSettings input) => + group.MapPost("/settings/{accountId:int}", (IServiceProvider sp, TerminalLogger logger, int accountId, RfSettings input) => { input.AccountId = accountId; var repo = sp.GetService(); - if (repo != null) - { - try { repo.Upsert(input); } - catch { } - } + + var failed = PersistenceGuard.Write(repo != null, () => repo!.Upsert(input), logger, + $"ResolutionFarming-Einstellungen fuer Konto {accountId}"); + if (failed != null) return failed; return Results.Ok(new { diff --git a/src/PolyTrader.Web/Endpoints/SupervisorEndpoints.cs b/src/PolyTrader.Web/Endpoints/SupervisorEndpoints.cs index 801c388..c10ae71 100644 --- a/src/PolyTrader.Web/Endpoints/SupervisorEndpoints.cs +++ b/src/PolyTrader.Web/Endpoints/SupervisorEndpoints.cs @@ -78,7 +78,16 @@ namespace PolyTrader.Web.Endpoints }; reportRepo.Insert(report); } - catch { /* Ignoriert bei DB-Offline */ } + catch (Exception ex) + { + // Bewusst nicht abbrechen (#534): Die Antwort des Agenten steht bereits und + // hat Geld gekostet — sie wegen eines fehlgeschlagenen Archiveintrags zu + // verwerfen, waere schlimmer als der fehlende Eintrag. Anders als bei + // Risikolimits haengt an diesem Schreibvorgang kein Verhalten des Systems. + // Stillschweigend darf er aber auch nicht verschwinden. + sp.GetService() + ?.Error($"Supervisor-Bericht nicht archiviert: {ex.Message}"); + } } return Results.Ok(new diff --git a/src/PolyTrader.Web/wwwroot/js/app.js b/src/PolyTrader.Web/wwwroot/js/app.js index df447f0..1aa1724 100644 --- a/src/PolyTrader.Web/wwwroot/js/app.js +++ b/src/PolyTrader.Web/wwwroot/js/app.js @@ -524,6 +524,19 @@ function escapeHtml(str) { // Zeichenkette als JS-Literal in ein onclick-Attribut. escapeHtml allein genuegt // dort nicht: Es laesst ' unberuehrt, und ein Name wie O'Brien beendet das // Literal vorzeitig - der Knopf ist dann tot. +/** + * Fehlertext einer abgelehnten Antwort. Die Schreibwege liefern seit #534 eine Begruendung + * mit ("... Es wurde nichts geaendert."); ein blosses "HTTP 500" verschenkt genau die. + */ +async function errorText(res) { + try { + const body = await res.json(); + return body.detail || body.error || body.title || `HTTP ${res.status}`; + } catch (e) { + return `HTTP ${res.status}`; + } +} + function jsArg(str) { return escapeHtml(JSON.stringify(String(str === undefined || str === null ? '' : str))); } @@ -1138,7 +1151,7 @@ async function saveTraderModal(e) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); - if (!res.ok) throw new Error('HTTP ' + res.status); + if (!res.ok) throw new Error(await errorText(res)); closeTraderModal(); loadCtTraders(); } catch (err) { @@ -1150,7 +1163,7 @@ async function deleteTrader(id, name) { if (!confirm(`Master-Trader #${id} (${name}) wirklich löschen?`)) return; try { const res = await fetch(`/api/modules/copytrading/traders/${id}`, { method: 'DELETE' }); - if (!res.ok) throw new Error('HTTP ' + res.status); + if (!res.ok) throw new Error(await errorText(res)); loadCtTraders(); } catch (err) { alert('Fehler beim Löschen: ' + err.message); @@ -1381,7 +1394,7 @@ async function saveCtSettings(e) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); - if (!res.ok) throw new Error('HTTP ' + res.status); + if (!res.ok) throw new Error(await errorText(res)); const statusEl = document.getElementById('ctSettingsStatus'); if (statusEl) { @@ -1666,7 +1679,7 @@ async function saveRfSettings(e) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); - if (!res.ok) throw new Error('HTTP ' + res.status); + if (!res.ok) throw new Error(await errorText(res)); const statusEl = document.getElementById('rfSettingsStatus'); if (statusEl) { diff --git a/tests/PolyTrader.Tests/WebUi/PersistenceGuardTests.cs b/tests/PolyTrader.Tests/WebUi/PersistenceGuardTests.cs new file mode 100644 index 0000000..243a7b0 --- /dev/null +++ b/tests/PolyTrader.Tests/WebUi/PersistenceGuardTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; +using PolyTrader.Web.Endpoints; +using PolyTraderSharp.Services; +using Xunit; + +namespace PolyTrader.Tests.WebUi +{ + /// + /// Der gemeinsame Schreibweg der Einstellungs-Endpunkte (#534). + /// + /// Der Kern ist nicht „meldet einen Fehler", sondern „lässt den Hauptspeicher in Ruhe": + /// Vorher wurde die Änderung auch nach einem gescheiterten Schreibvorgang übernommen und als + /// Erfolg gemeldet. Bei Risikolimits heißt das, dass das Modul mit Werten handelt, die nirgends + /// stehen und nach einem Neustart nicht mehr rekonstruierbar sind. + /// + public class PersistenceGuardTests + { + private static TerminalLogger Logger() => new(); + + [Fact] + public void Gelungenes_Schreiben_gibt_keine_Fehlerantwort() + { + bool geschrieben = false; + + var result = PersistenceGuard.Write(true, () => geschrieben = true, Logger(), "Testwert"); + + Assert.Null(result); // null = der Aufrufer darf nachziehen + Assert.True(geschrieben); + } + + [Fact] + public void Ausnahme_beim_Schreiben_liefert_eine_Fehlerantwort() + { + var result = PersistenceGuard.Write( + true, + () => throw new InvalidOperationException("Verbindung weg"), + Logger(), + "CopyTrading-Einstellungen fuer Konto 1"); + + Assert.NotNull(result); + Assert.IsAssignableFrom(result); + Assert.Equal(StatusCodes.Status500InternalServerError, ((IStatusCodeHttpResult)result!).StatusCode); + } + + [Fact] + public void Fehlende_Ablage_wird_ebenso_abgelehnt() + { + bool geschrieben = false; + + // Ohne Repository waere die Aenderung rein fluechtig — also dasselbe Problem. + var result = PersistenceGuard.Write(false, () => geschrieben = true, Logger(), "Testwert"); + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status503ServiceUnavailable, ((IStatusCodeHttpResult)result!).StatusCode); + Assert.False(geschrieben); + } + + /// + /// Das eigentliche Versprechen, nachgestellt am Ablauf des Endpunkts: Erst schreiben, den + /// Hauptspeicher nur bei Erfolg nachziehen. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Hauptspeicher_wird_nur_bei_Erfolg_nachgezogen(bool schreibenGelingt) + { + var hauptspeicher = new Dictionary { [1] = 5.00m }; + + var result = PersistenceGuard.Write( + true, + () => { if (!schreibenGelingt) throw new InvalidOperationException("DB weg"); }, + Logger(), + "Risikolimit Konto 1"); + + if (result == null) + hauptspeicher[1] = 7.50m; + + Assert.Equal(schreibenGelingt ? 7.50m : 5.00m, hauptspeicher[1]); + } + } +}