EventApplier.ApplyAsync fing bisher nur LiteException ab - jede andere Ausnahme (z.B. eine fehlerhafte Entschlüsselung/Deserialisierung eines einzelnen Ereignisses) fiel unbehandelt aus der Pull-Schleife in SyncEngine.PullAsync heraus, bevor der Fortschritt (SetLastServerSeq) gespeichert wurde. Der nächste Sync-Versuch lud denselben Batch erneut und scheiterte am selben Ereignis wieder - ein dauerhaft blockierter Sync, bei dem selbst bereits erfolgreich angewendete Ereignisse im selben Batch nie als erledigt markiert wurden. Zusätzlich protokollierte weder SyncEngine noch EventApplier irgendetwas, ein Fehlschlag zeigte sich höchstens als knapper Text in der Sync-Statusleiste. EventApplier fängt jetzt jede Ausnahme pro Ereignis ab (geloggt über AppLogger, übersprungen statt den Batch zu blockieren); SyncEngine loggt jeden Sync-Fehlschlag vollständig. Betrifft nur den Desktop-Client, kein API-Redeploy nötig. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
157 lines
8.3 KiB
C#
157 lines
8.3 KiB
C#
using System.Text;
|
|
using JsonSerializer = System.Text.Json.JsonSerializer;
|
|
using LehrerApp.Core.Models;
|
|
using LehrerApp.Core.Services;
|
|
using LehrerApp.Data;
|
|
using LehrerApp.Sync.Crypto;
|
|
using LehrerApp.Sync.Models;
|
|
using LiteDB;
|
|
|
|
namespace LehrerApp.Sync;
|
|
|
|
/// <summary>
|
|
/// Wendet ein von <see cref="SyncEngine"/> empfangenes (und nicht durch einen Konflikt
|
|
/// verlorenes) Ereignis auf die lokale Datenbank an.
|
|
///
|
|
/// Schreibt IMMER direkt auf die rohe LiteDB-Collection, nie über eine Repository-
|
|
/// Save/Delete-Methode — sonst würde <see cref="LiteDbContext.OnChange"/> erneut feuern und die
|
|
/// gerade angewendete Änderung als neues ausgehendes Ereignis re-enqueuen (Sync-Ping-Pong).
|
|
/// Ein gemeinsames Suppress-Flag wurde bewusst verworfen: SyncEngine läuft per Timer nebenläufig
|
|
/// zum UI-Thread, ein Flag könnte während eines laufenden Pulls einen echten Nutzer-Save
|
|
/// verschlucken. Der direkte Collection-Zugriff ist zustandslos und dadurch korrekt.
|
|
///
|
|
/// Weiche Geschäftsregeln (z.B. ArchivedGroupWriteGuard, Namens-Eindeutigkeit) werden auf diesem
|
|
/// Pfad bewusst NICHT geprüft (v1-Einschränkung, siehe TODO.md 10.3) — nur harte LiteDB-Unique-
|
|
/// Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses.
|
|
/// </summary>
|
|
public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = null, AppLogger? logger = null)
|
|
{
|
|
private static readonly Dictionary<string, EntityHandler> Handlers = BuildHandlers();
|
|
|
|
public async Task ApplyAsync(SyncEvent evt)
|
|
{
|
|
if (!Handlers.TryGetValue(evt.EntityType, out var handler))
|
|
{
|
|
logger?.Warn($"Sync: kein Handler für Entitätstyp '{evt.EntityType}' (EntityId={evt.EntityId}, " +
|
|
$"Operation={evt.Operation}) - Ereignis übersprungen.");
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
var json = evt.Payload.Length == 0 ? "" : Decrypt(evt.Payload);
|
|
handler(db, evt.Operation, evt.EntityId, json);
|
|
if (evt.EntityType == nameof(Documentation) && evt.Operation != "Delete" && http is not null)
|
|
await DownloadMissingAttachmentsAsync(json);
|
|
}
|
|
catch (LiteException ex)
|
|
{
|
|
// Harte Constraint-Verletzung (z.B. Unique-Index) - dieses eine Ereignis
|
|
// überspringen, statt den gesamten Sync-Lauf abzubrechen.
|
|
logger?.Error($"Sync: Ereignis übersprungen (LiteDB-Constraint) - {evt.EntityType} " +
|
|
$"{evt.Operation} EntityId={evt.EntityId}", ex);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Jede andere Ausnahme (z.B. Entschlüsselung/Deserialisierung fehlgeschlagen) darf
|
|
// NICHT aus ApplyAsync herausfallen: SyncEngine.PullAsync ruft dies in einer Schleife
|
|
// über einen ganzen Ereignis-Batch auf, ohne eigenes try/catch - eine unbehandelte
|
|
// Ausnahme hier würde den kompletten Pull-Lauf abbrechen, BEVOR SetLastServerSeq
|
|
// aufgerufen wird. Der Pull würde beim nächsten Versuch denselben Batch erneut laden
|
|
// und an genau demselben Ereignis wieder scheitern - ein dauerhaft blockierter Sync,
|
|
// bei dem selbst bereits erfolgreich angewendete Ereignisse im selben Batch nie als
|
|
// erledigt markiert werden.
|
|
logger?.Error($"Sync: Ereignis konnte nicht angewendet werden - {evt.EntityType} " +
|
|
$"{evt.Operation} EntityId={evt.EntityId}", ex);
|
|
}
|
|
}
|
|
|
|
// Anhang-Bytes reisen nicht im JSON-Ereignis mit (siehe SyncEventPublisher) - nach dem
|
|
// Anwenden der Documentation-Metadaten fehlende, lokal noch nicht vorhandene Anhänge einzeln
|
|
// nachladen. Gegenstück zum Upload in AttachmentSyncer.
|
|
private async Task DownloadMissingAttachmentsAsync(string json)
|
|
{
|
|
var doc = JsonSerializer.Deserialize<Documentation>(json);
|
|
if (doc is null) return;
|
|
foreach (var attachment in doc.Attachments)
|
|
{
|
|
if (db.Attachments.Exists(attachment.StorageId)) continue;
|
|
var resp = await http!.GetAsync($"/api/sync/attachments/{attachment.StorageId}");
|
|
if (!resp.IsSuccessStatusCode) continue;
|
|
var encrypted = await resp.Content.ReadAsByteArrayAsync();
|
|
var decrypted = SyncCrypto.Decrypt(encrypted, syncKey);
|
|
using var stream = new MemoryStream(decrypted);
|
|
// Über die rohe Collection statt IAttachmentStorage.Upload, da dieses immer eine
|
|
// neue Id vergibt - hier muss die Original-StorageId erhalten bleiben.
|
|
db.Attachments.Upload(attachment.StorageId, attachment.FileName, stream);
|
|
}
|
|
}
|
|
|
|
private string Decrypt(string payloadBase64) =>
|
|
Encoding.UTF8.GetString(SyncCrypto.Decrypt(Convert.FromBase64String(payloadBase64), syncKey));
|
|
|
|
private delegate void EntityHandler(LiteDbContext db, string operation, string entityId, string json);
|
|
|
|
private static Dictionary<string, EntityHandler> BuildHandlers()
|
|
{
|
|
var handlers = new Dictionary<string, EntityHandler>();
|
|
|
|
void Simple<T>(Func<LiteDbContext, ILiteCollection<T>> collection) where T : class =>
|
|
handlers[typeof(T).Name] = (context, operation, entityId, json) =>
|
|
{
|
|
if (operation == "Delete") collection(context).Delete(new Guid(entityId));
|
|
else collection(context).Upsert(JsonSerializer.Deserialize<T>(json)!);
|
|
};
|
|
|
|
Simple<Student>(context => context.Students);
|
|
Simple<SeatingPlan>(context => context.SeatingPlans);
|
|
Simple<GroupMembership>(context => context.Memberships);
|
|
Simple<GradingKeyTemplate>(context => context.GradingKeyTemplates);
|
|
Simple<Grade>(context => context.Grades);
|
|
Simple<GradingScheme>(context => context.GradingSchemes);
|
|
Simple<ReportGrade>(context => context.ReportGrades);
|
|
Simple<Unit>(context => context.Units);
|
|
Simple<Lesson>(context => context.Lessons);
|
|
Simple<WorkTask>(context => context.Tasks);
|
|
Simple<TimeEntry>(context => context.TimeEntries);
|
|
Simple<ExamResult>(context => context.ExamResults);
|
|
Simple<ParticipationEntry>(context => context.ParticipationEntries);
|
|
Simple<ParticipationAspect>(context => context.ParticipationAspects);
|
|
Simple<ParticipationSection>(context => context.ParticipationSections);
|
|
Simple<Subject>(context => context.Subjects);
|
|
Simple<ShorthandCode>(context => context.ShorthandCodes);
|
|
Simple<AlternativeLessonPath>(context => context.AlternativeLessonPaths);
|
|
Simple<TimetableSlot>(context => context.TimetableSlots);
|
|
Simple<SchoolHoliday>(context => context.SchoolHolidays);
|
|
Simple<SupervisionDuty>(context => context.SupervisionDuties);
|
|
Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
|
|
Simple<CompetencyDomain>(context => context.CompetencyDomains);
|
|
|
|
// Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen
|
|
// Repositories, damit die Kaskade nur an einer Stelle im Code existiert.
|
|
handlers[nameof(LearningGroup)] = (context, operation, entityId, json) =>
|
|
{
|
|
if (operation == "Delete") context.CascadeDeleteGroup(new Guid(entityId));
|
|
else context.Groups.Upsert(JsonSerializer.Deserialize<LearningGroup>(json)!);
|
|
};
|
|
handlers[nameof(Exam)] = (context, operation, entityId, json) =>
|
|
{
|
|
if (operation == "Delete") context.CascadeDeleteExam(new Guid(entityId));
|
|
else context.Exams.Upsert(JsonSerializer.Deserialize<Exam>(json)!);
|
|
};
|
|
handlers[nameof(ParticipationSession)] = (context, operation, entityId, json) =>
|
|
{
|
|
if (operation == "Delete") context.CascadeDeleteParticipationSession(new Guid(entityId));
|
|
else context.ParticipationSessions.Upsert(JsonSerializer.Deserialize<ParticipationSession>(json)!);
|
|
};
|
|
// "Delete" ist hier das harte Löschen (samt Anhängen) - das weiche Löschen kommt als
|
|
// "Save" mit IsDeleted=true und läuft über den generischen Upsert-Zweig.
|
|
handlers[nameof(Documentation)] = (context, operation, entityId, json) =>
|
|
{
|
|
if (operation == "Delete") context.CascadeHardDeleteDocumentation(new Guid(entityId));
|
|
else context.Documentation.Upsert(JsonSerializer.Deserialize<Documentation>(json)!);
|
|
};
|
|
|
|
return handlers;
|
|
}
|
|
}
|