Anhaenge laufen bewusst NICHT ueber den JSON-Ereigniskanal (wuerde ihn
fuer Fotos/Scans stark aufblaehen), sondern ueber einen eigenen
verschluesselten Binaerkanal - analog zum bereits bestehenden Muster
in SnapshotService.
- Neue Endpunkte POST/GET /api/sync/attachments/{storageId} in
LehrerApp.Api (AttachmentStore, dateibasiert je Nutzer)
- EventQueue: neue, vom JSON-Ereignis getrennte Warteliste fuer
ausstehende Uploads (SyncEventPublisher traegt Anhaenge einer
gespeicherten Documentation dort ein)
- AttachmentSyncer laedt ausstehende Anhaenge hoch (in
SyncEngine.SyncNowAsync nach dem Event-Push)
- EventApplier laedt fehlende Anhaenge nach dem Anwenden eines
Documentation-Ereignisses nach - ueber die rohe Collection statt
IAttachmentStorage.Upload, da dieses immer eine neue Id vergaebe und
hier die Original-StorageId erhalten bleiben muss
Round-Trip-Tests belegen byteidentische Uebertragung.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
136 lines
7.0 KiB
C#
136 lines
7.0 KiB
C#
using System.Text;
|
|
using JsonSerializer = System.Text.Json.JsonSerializer;
|
|
using LehrerApp.Core.Models;
|
|
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)
|
|
{
|
|
private static readonly Dictionary<string, EntityHandler> Handlers = BuildHandlers();
|
|
|
|
public async Task ApplyAsync(SyncEvent evt)
|
|
{
|
|
if (!Handlers.TryGetValue(evt.EntityType, out var handler)) 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)
|
|
{
|
|
// Harte Constraint-Verletzung (z.B. Unique-Index) - dieses eine Ereignis
|
|
// überspringen, statt den gesamten Sync-Lauf abzubrechen.
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|