feat: Web-UI als zweiter Ausgabeweg neben Avalonia-Shell (#490)
Build & Test / build (ubuntu-latest) (push) Canceled after 0s
Build & Test / build (windows-latest) (push) Canceled after 0s

- Einheitlicher WebserverWorker in IBKRTrader.Hosting auf Kestrel-Basis
- Token-Authentifizierung (AuthService) mit zeitkonstantem Vergleich und HMAC-Session-Tokens
- OrderManager im Core fuer Bestands- und Reservierungsabgleich offener Broker-Orders
- SettingsSchema fuer reflexionsbasiertes Auslesen und sichere Aktualisierung ohne Klartext-Passwoerter
- SSE-Streaming (LogStreamService) fuer Echtzeit-Logs ueber isolierte Channels
- Statische Single-Page-Application in wwwroot (Avalonia Fluent / Polytrader Theme) fuer alle 9 Ansichten
- Unit-Tests fuer OrderManager, AuthService, SettingsSchema, LogStreamService und WebserverWorker (498 Tests gruen)
This commit is contained in:
Richard
2026-09-06 19:55:40 +02:00
parent 7299148dc4
commit f923721784
35 changed files with 4856 additions and 370 deletions
@@ -0,0 +1,236 @@
using System.ComponentModel;
using System.Globalization;
using System.Reflection;
namespace IBKRTrader.Core.Settings;
/// <summary>
/// Beschreibung eines einzelnen bearbeitbaren Einstellungsfelds für Oberflächen (Desktop & Web).
/// </summary>
public sealed class SettingsFieldInfo
{
public required string Path { get; init; }
public required string DisplayName { get; init; }
public required string Description { get; init; }
public required Type ValueType { get; init; }
public required string TypeName { get; init; }
public required bool IsPassword { get; init; }
public string[]? EnumValues { get; init; }
public required Func<object?> Get { get; init; }
public required Action<object?> Set { get; init; }
}
/// <summary>
/// Ein logischer Abschnitt von Einstellungen (z. B. "Trading", "Webserver", "Datenbank").
/// </summary>
public sealed record SettingsSectionInfo(string Title, string Key, IReadOnlyList<SettingsFieldInfo> Fields);
/// <summary>
/// DTO-Struktur für die Web-API: Serialisierbare Repräsentation eines Felds.
/// Geheimnisse (Passwörter/Tokens) werden niemals lesend herausgegeben.
/// </summary>
public sealed record SettingsFieldDto(
string Path,
string DisplayName,
string Description,
string TypeName,
bool IsPassword,
bool HasValue,
object? Value,
string[]? EnumValues);
/// <summary>
/// DTO-Struktur für die Web-API: Serialisierbare Repräsentation eines Abschnitts.
/// </summary>
public sealed record SettingsSectionDto(
string Title,
string Key,
IReadOnlyList<SettingsFieldDto> Fields);
/// <summary>
/// Erstellt strukturierte Einstellungsmetadaten aus den Attributen der <see cref="AppSettings"/>.
/// Geteilt zwischen Desktop (Avalonia) und Web (Minimal API), damit beide exakt dieselbe
/// Struktur und dieselben Validierungsregeln verwenden.
/// </summary>
public static class SettingsSchema
{
private static bool IsLeaf(Type t) =>
t == typeof(string) || t.IsEnum ||
t == typeof(int) || t == typeof(long) || t == typeof(double) ||
t == typeof(decimal) || t == typeof(bool);
private static string TypeToName(Type t)
{
if (t == typeof(bool)) return "bool";
if (t.IsEnum) return "enum";
if (t == typeof(int) || t == typeof(long)) return "int";
if (t == typeof(double) || t == typeof(decimal)) return "decimal";
return "string";
}
/// <summary>
/// Analysiert das Konfigurationsobjekt per Reflection und erzeugt die Abschnittsliste.
/// </summary>
public static IReadOnlyList<SettingsSectionInfo> Build(object root)
{
var sections = new List<SettingsSectionInfo>();
Walk(root, prefixTitle: null, prefixKey: null, sections);
return sections;
}
/// <summary>
/// Erzeugt ein sicheres DTO für die Web-API. Passwörter und Tokens werden maskiert.
/// </summary>
public static IReadOnlyList<SettingsSectionDto> ToDto(object root)
{
var sections = Build(root);
return sections.Select(s => new SettingsSectionDto(
Title: s.Title,
Key: s.Key,
Fields: s.Fields.Select(f =>
{
var val = f.Get();
var hasVal = val != null && (!(val is string str) || !string.IsNullOrWhiteSpace(str));
return new SettingsFieldDto(
Path: f.Path,
DisplayName: f.DisplayName,
Description: f.Description,
TypeName: f.TypeName,
IsPassword: f.IsPassword,
HasValue: hasVal,
// Wenn IsPassword true ist, wird der echte Wert niemals herausgegeben!
Value: f.IsPassword ? null : val,
EnumValues: f.EnumValues);
}).ToList())).ToList();
}
/// <summary>
/// Wendet einen vom Web-Client übergebenen Wert auf das passende Feld an.
/// </summary>
public static bool TrySetValue(object root, string path, object? rawValue)
{
var sections = Build(root);
var field = sections.SelectMany(s => s.Fields)
.FirstOrDefault(f => string.Equals(f.Path, path, StringComparison.OrdinalIgnoreCase));
if (field == null) return false;
// Wenn ein Passwortfeld leer übergeben wird, bleibt das alte Kennwort unberührt
if (field.IsPassword && (rawValue == null || (rawValue is string s && string.IsNullOrWhiteSpace(s))))
return true;
try
{
if (field.ValueType == typeof(bool))
{
var b = rawValue switch
{
bool boolVal => boolVal,
string strVal => bool.TryParse(strVal, out var parsed) && parsed,
_ => false
};
field.Set(b);
return true;
}
if (field.ValueType.IsEnum)
{
var str = rawValue?.ToString();
if (str != null && Enum.TryParse(field.ValueType, str, true, out var enumVal))
{
field.Set(enumVal);
return true;
}
return false;
}
if (field.ValueType == typeof(int))
{
var num = Convert.ToInt32(rawValue, CultureInfo.InvariantCulture);
field.Set(num);
return true;
}
if (field.ValueType == typeof(decimal))
{
var dec = Convert.ToDecimal(rawValue, CultureInfo.InvariantCulture);
field.Set(dec);
return true;
}
if (field.ValueType == typeof(double))
{
var dbl = Convert.ToDouble(rawValue, CultureInfo.InvariantCulture);
field.Set(dbl);
return true;
}
field.Set(rawValue?.ToString() ?? "");
return true;
}
catch
{
return false;
}
}
private static void Walk(object owner, string? prefixTitle, string? prefixKey, List<SettingsSectionInfo> sections)
{
var fields = new List<SettingsFieldInfo>();
foreach (var prop in owner.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (!prop.CanRead || prop.GetIndexParameters().Length > 0) continue;
var title = prop.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? prop.Name;
var key = prefixKey == null ? prop.Name : $"{prefixKey}.{prop.Name}";
if (IsLeaf(prop.PropertyType))
{
if (!prop.CanWrite) continue;
var target = owner;
var isPassword = prop.GetCustomAttribute<PasswordPropertyTextAttribute>()?.Password == true ||
prop.Name.Contains("Password", StringComparison.OrdinalIgnoreCase) ||
prop.Name.Contains("ApiToken", StringComparison.OrdinalIgnoreCase) ||
prop.Name.Contains("AuthToken", StringComparison.OrdinalIgnoreCase);
fields.Add(new SettingsFieldInfo
{
Path = key,
DisplayName = title,
Description = prop.GetCustomAttribute<DescriptionAttribute>()?.Description ?? "",
ValueType = prop.PropertyType,
TypeName = TypeToName(prop.PropertyType),
IsPassword = isPassword,
EnumValues = prop.PropertyType.IsEnum ? Enum.GetNames(prop.PropertyType) : null,
Get = () => prop.GetValue(target),
Set = v => prop.SetValue(target, v)
});
continue;
}
if (prop.PropertyType.IsClass && prop.PropertyType.Namespace?.StartsWith("IBKRTrader", StringComparison.Ordinal) == true)
{
var child = prop.GetValue(owner);
if (child != null)
{
Walk(child,
prefixTitle == null ? title : $"{prefixTitle} · {title}",
key,
sections);
}
}
}
if (fields.Count > 0)
{
sections.Add(new SettingsSectionInfo(
Title: prefixTitle ?? "Allgemein",
Key: prefixKey ?? "Root",
Fields: fields));
}
}
}