fix: Kontowechsel ließ Push dauerhaft mit 404 scheitern

Nach einem Login mit korrigierter Groß-/Kleinschreibung (siehe vorheriger Fix zur userId-
Stabilität) bezog sich die lokale Versionsverfolgung je Entität (BasedOnServerSeq-Cache) und der
Pull-Cursor weiterhin auf das alte Konto - ServerSeq-Werte sind aber nur innerhalb des Event-Logs
EINES Kontos gültig. Der Push wurde zu Recht abgelehnt, der Server kannte die Entität unter der
neuen userId aber gar nicht (404 beim Nachladen), und HandleRejectedAsync gab bei einem 404
bisher einfach auf, ohne den veralteten Cache-Eintrag zu bereinigen - derselbe Fehlschlag bei
jedem weiteren Sync-Versuch.

Dreiteiliger Fix: (1) ein 404 beim Nachladen löscht jetzt den stale Cache-Eintrag, sodass der
nächste Push die Entität korrekt als neu behandelt und selbstheilend durchgeht; (2) der
"Vollständigen Sync erzwingen"-Button setzt jetzt auch die Push-Versionsverfolgung zurück, nicht
nur den Pull-Cursor; (3) SyncLogin erkennt einen echten Kontowechsel künftig proaktiv anhand der
kanonischen userId aus der Server-Antwort und resettet automatisch, bevor der Folgefehler
überhaupt auftreten kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 00:05:24 +02:00
co-authored by Claude Sonnet 5
parent ba8786906a
commit 979511ab6a
9 changed files with 229 additions and 13 deletions
+11 -2
View File
@@ -15,7 +15,14 @@ public enum SyncConnectionTestResult { Ok, Unauthorized, Unreachable }
/// </summary>
public class SyncAuthService(HttpClient http)
{
public async Task<string> LoginAsync(string serverUrl, string username, string password)
/// <summary>
/// Liefert neben dem Token auch die kanonische userId aus der Server-Antwort - LiteDBs
/// case-insensitive Standard-Collation lässt einen Login mit abweichender
/// Groß-/Kleinschreibung des Benutzernamens erfolgreich durch (siehe TODO 10.2.5); die
/// zurückgegebene userId ist deshalb der einzige verlässliche Weg für den Aufrufer, einen
/// Kontowechsel von einer bloßen Schreibweisen-Abweichung zu unterscheiden (siehe TODO 10.3.5).
/// </summary>
public async Task<(string Token, string UserId)> LoginAsync(string serverUrl, string username, string password)
{
HttpResponseMessage resp;
try
@@ -34,7 +41,9 @@ public class SyncAuthService(HttpClient http)
throw new SyncAuthException("Anmeldung fehlgeschlagen. Bitte später erneut versuchen.");
var result = await resp.Content.ReadFromJsonAsync<LoginResult>();
return result?.Token ?? throw new SyncAuthException("Unerwartete Antwort des Sync-Servers.");
return result is null
? throw new SyncAuthException("Unerwartete Antwort des Sync-Servers.")
: (result.Token, result.UserId);
}
public async Task<SyncConnectionTestResult> TestConnectionAsync(string serverUrl, string? token)
@@ -8,6 +8,10 @@ internal class SyncSettingsConfig
public string ServerUrl { get; set; } = "";
public string Username { get; set; } = "";
public string? EncryptedToken { get; set; }
/// <summary>Kanonische userId aus der letzten erfolgreichen Login-Antwort (siehe
/// SettingsViewModel.SyncLogin) — dient dem Erkennen eines Kontowechsels, unabhängig von der
/// beim Login eingegebenen Groß-/Kleinschreibung des Benutzernamens (siehe TODO 10.3.5).</summary>
public string? LastUserId { get; set; }
}
/// <summary>
@@ -27,6 +31,7 @@ public class SyncSettingsService
public string ServerUrl => _config.ServerUrl;
public string Username => _config.Username;
public string? LastUserId => _config.LastUserId;
public bool IsLoggedIn => _config.EncryptedToken is not null;
public SyncSettingsService(string appDataPath)
@@ -43,10 +48,11 @@ public class SyncSettingsService
Save();
}
public void SetCredentialsAndToken(string username, string token)
public void SetCredentialsAndToken(string username, string token, string? userId = null)
{
_config.Username = username;
_config.EncryptedToken = SyncCrypto.EncryptObject(token, _tokenKey);
if (userId is not null) _config.LastUserId = userId;
Save();
}
@@ -477,9 +477,22 @@ public partial class SettingsViewModel : ObservableObject
try
{
var token = await _syncAuth.LoginAsync(SyncServerUrl, SyncUsername, SyncPassword);
var (token, userId) = await _syncAuth.LoginAsync(SyncServerUrl, SyncUsername, SyncPassword);
// Kontowechsel-Erkennung anhand der KANONISCHEN userId aus der Server-Antwort, nicht
// anhand von SyncUsername: LiteDBs case-insensitive Standard-Collation lässt einen
// Login mit abweichender Groß-/Kleinschreibung erfolgreich durch (TODO 10.2.5), meldet
// serverseitig aber trotzdem eine ANDERE userId zurück - die lokale Versionsverfolgung
// (BasedOnServerSeq je Entität, Pull-Cursor) bezieht sich dann auf ein fremdes Konto
// und muss verworfen werden, sonst lehnt der Server jeden weiteren Push mit einem
// dauerhaften 404 beim Nachladen ab (siehe TODO 10.3.5).
var accountChanged = _syncSettings.LastUserId is not null && _syncSettings.LastUserId != userId;
_syncSettings.SetServerUrl(SyncServerUrl);
_syncSettings.SetCredentialsAndToken(SyncUsername, token);
_syncSettings.SetCredentialsAndToken(SyncUsername, token, userId);
if (accountChanged)
{
_eventQueue.SetLastServerSeq(0);
_eventQueue.ResetKnownServerSeqs();
}
SyncPassword = "";
AppBootstrapper.RestartApplication();
}
@@ -494,11 +507,13 @@ public partial class SettingsViewModel : ObservableObject
}
/// <summary>
/// Setzt den lokalen Sync-Fortschritt zurück und lädt danach alle Ereignisse dieses Nutzers
/// erneut vom Server - manueller Reparaturweg, falls dieses Gerät wiederholt keine Änderungen
/// eines anderen Geräts erhält (z.B. nach einem lokal bereits zu weit vorgerückten Cursor,
/// siehe TODO 10.1.10). Sicher wiederholbar: EventApplier wendet jedes Ereignis über Upsert/
/// Delete-by-Id idempotent an.
/// Setzt den lokalen Sync-Fortschritt (Pull-Cursor UND die per-Entität-Versionsverfolgung für
/// Push) vollständig zurück und lädt danach alle Ereignisse dieses Nutzers erneut vom Server -
/// manueller Reparaturweg, falls dieses Gerät wiederholt keine Änderungen eines anderen Geräts
/// erhält (z.B. nach einem lokal bereits zu weit vorgerückten Cursor, TODO 10.1.10) oder eigene
/// Pushes dauerhaft mit 404 abgelehnt werden (z.B. nach einem Kontowechsel, TODO 10.3.5).
/// Sicher wiederholbar: EventApplier wendet jedes Ereignis über Upsert/Delete-by-Id idempotent
/// an, und ein Push ohne bekannten Vorstand wird vom Server als Neuanlage behandelt.
/// </summary>
[RelayCommand]
private async Task SyncForceFullResync()
@@ -509,6 +524,7 @@ public partial class SettingsViewModel : ObservableObject
try
{
_eventQueue.SetLastServerSeq(0);
_eventQueue.ResetKnownServerSeqs();
var result = await _syncEngine.SyncNowAsync();
SyncForceResyncStatus = result.Success
? $"Abgeschlossen - {result.EventsPulled} Ereignis(se) erneut geladen."