Files
LehrerApp/LehrerApp.Sync/EventApplier.cs
T
2026-09-29 22:17:49 +02:00

245 lines
13 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,
EventQueue? versions = null)
{
private static readonly Dictionary<string, EntityHandler> Handlers = BuildHandlers();
private static readonly Dictionary<string, Func<string, IHasAttachments?>> AttachmentDeserializers = new()
{
[nameof(Documentation)] = json => JsonSerializer.Deserialize<Documentation>(json),
[nameof(Lesson)] = json => JsonSerializer.Deserialize<Lesson>(json),
};
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.Operation != "Delete" && http is not null &&
AttachmentDeserializers.TryGetValue(evt.EntityType, out var deserialize))
await DownloadMissingAttachmentsAsync(deserialize(json));
// evt.SequenceNr trägt bei einem vom Server empfangenen Ereignis immer dessen
// ServerSeq (siehe EventStore.Pull) — Grundlage für BasedOnServerSeq beim nächsten
// eigenen Push dieser Entität (optimistische Nebenläufigkeitskontrolle, TODO 10.3.4).
versions?.SetKnownServerSeq(evt.EntityType, evt.EntityId, evt.SequenceNr);
logger?.Info($"Sync: Ereignis angewendet - {evt.EntityType} {evt.Operation} EntityId={evt.EntityId}");
}
catch (SyncProtocolMismatchException)
{
// Anders als ein einzelnes korruptes Ereignis betrifft dies den gesamten Batch. Der
// Pull-Cursor darf nicht vorrücken, solange Client und Server inkompatibel sind.
throw;
}
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 Metadaten fehlende, lokal noch nicht vorhandene Anhänge einzeln nachladen.
// Gegenstück zum Upload in AttachmentSyncer.
private async Task DownloadMissingAttachmentsAsync(IHasAttachments? entity)
{
if (entity is null) return;
foreach (var attachment in entity.Attachments)
{
if (db.Attachments.Exists(attachment.StorageId)) continue;
using var request = SyncProtocol.CreateRequest(HttpMethod.Get,
$"/api/sync/attachments/{attachment.StorageId}");
using var resp = await http!.SendAsync(request);
if (resp.StatusCode == System.Net.HttpStatusCode.UpgradeRequired)
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
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<StudentGroupPlan>(context => context.StudentGroupPlans);
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<Vorgang>(context => context.Vorgaenge);
Simple<UntisHubJobState>(context => context.UntisHubJobStates);
Simple<DashboardWarningSnooze>(context => context.DashboardWarningSnoozes);
handlers[nameof(CompetencyCatalogVersion)] = (context, operation, entityId, json) =>
{
if (operation == "Delete")
{
context.CompetencyCatalogVersions.Delete(new Guid(entityId));
return;
}
var version = JsonSerializer.Deserialize<CompetencyCatalogVersion>(json)!;
context.CompetencyCatalogVersions.Upsert(version);
NormalizeCompetencyDefaults(context, version.SubjectId, version.Id);
};
handlers[nameof(CompetencyDomain)] = (context, operation, entityId, json) =>
{
Guid? subjectId;
if (operation == "Delete")
{
subjectId = context.CompetencyDomains.FindById(new Guid(entityId))?.SubjectId;
context.CompetencyDomains.Delete(new Guid(entityId));
}
else
{
var domain = JsonSerializer.Deserialize<CompetencyDomain>(json)!;
subjectId = domain.SubjectId;
context.CompetencyDomains.Upsert(domain);
}
if (subjectId is Guid id) NormalizeCompetencyDefaults(context, id);
};
// 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;
}
/// <summary>
/// Alte, auf mehreren Geräten unabhängig erzeugte Standardversionen heißen identisch
/// „Bestehender Kompetenzkatalog“, besitzen aber unterschiedliche IDs. Bei diesem speziellen
/// Migrationsfall gewinnt deterministisch die Version mit den meisten Bereichen. Bei bewusst
/// verschieden benannten Versionen gilt dagegen die zuletzt empfangene Standard-Auswahl.
/// Direkte Collection-Schreibzugriffe verhindern dabei Sync-Ping-Pong.
/// </summary>
private static void NormalizeCompetencyDefaults(LiteDbContext context, Guid subjectId,
Guid? incomingDefaultId = null)
{
var defaults = context.CompetencyCatalogVersions
.Find(v => v.SubjectId == subjectId && v.IsDefault).ToList();
if (defaults.Count <= 1) return;
CompetencyCatalogVersion winner;
if (defaults.Select(v => v.Name.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Count() == 1)
{
winner = defaults
.OrderByDescending(v => context.CompetencyDomains.Count(d => d.VersionId == v.Id))
.ThenBy(v => v.Id)
.First();
}
else
{
winner = defaults.FirstOrDefault(v => v.Id == incomingDefaultId)
?? defaults.OrderBy(v => v.Id).First();
}
foreach (var version in defaults.Where(v => v.Id != winner.Id))
{
version.IsDefault = false;
context.CompetencyCatalogVersions.Upsert(version);
}
}
}