- 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)
237 lines
8.6 KiB
C#
237 lines
8.6 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|