Baustein 6: Datei-Anhaenge synchronisieren (Kapitel 10)
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>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Lädt ausstehende Datei-Anhänge (siehe <see cref="EventQueue.GetPendingAttachmentUploads"/>)
|
||||
/// als eigenen, verschlüsselten Binärtransfer hoch — getrennt vom JSON-Ereigniskanal, damit
|
||||
/// Fotos/Scans ihn nicht aufblähen. Gegenstück zum Download in <see cref="EventApplier"/>.
|
||||
/// </summary>
|
||||
public class AttachmentSyncer(LiteDbContext db, HttpClient http, byte[] syncKey)
|
||||
{
|
||||
public async Task UploadPendingAsync(EventQueue queue)
|
||||
{
|
||||
foreach (var storageId in queue.GetPendingAttachmentUploads())
|
||||
{
|
||||
if (!db.Attachments.Exists(storageId))
|
||||
{
|
||||
// Lokal inzwischen wieder gelöscht (z.B. HardDelete vor dem eigentlichen Upload) -
|
||||
// nichts hochzuladen, Warteliste trotzdem bereinigen.
|
||||
queue.MarkAttachmentUploaded(storageId);
|
||||
continue;
|
||||
}
|
||||
|
||||
using var raw = db.Attachments.OpenRead(storageId);
|
||||
using var buffer = new MemoryStream();
|
||||
await raw.CopyToAsync(buffer);
|
||||
var encrypted = SyncCrypto.Encrypt(buffer.ToArray(), syncKey);
|
||||
|
||||
using var content = new ByteArrayContent(encrypted);
|
||||
var resp = await http.PostAsync($"/api/sync/attachments/{storageId}", content);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
queue.MarkAttachmentUploaded(storageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,17 +23,19 @@ namespace LehrerApp.Sync;
|
||||
/// 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)
|
||||
public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = null)
|
||||
{
|
||||
private static readonly Dictionary<string, EntityHandler> Handlers = BuildHandlers();
|
||||
|
||||
public void Apply(SyncEvent evt)
|
||||
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)
|
||||
{
|
||||
@@ -42,6 +44,27 @@ public class EventApplier(LiteDbContext db, byte[] syncKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 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));
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ public class EventQueue : IDisposable
|
||||
private readonly ILiteCollection<SyncEvent> _queue;
|
||||
private readonly ILiteCollection<SyncMeta> _meta;
|
||||
private readonly ILiteCollection<ConflictEntry> _conflicts;
|
||||
private readonly ILiteCollection<PendingAttachmentUpload> _attachmentUploads;
|
||||
private long _currentSeq;
|
||||
|
||||
public EventQueue(string path)
|
||||
@@ -21,6 +22,8 @@ public class EventQueue : IDisposable
|
||||
_queue = _db.GetCollection<SyncEvent>("queue");
|
||||
_meta = _db.GetCollection<SyncMeta>("meta");
|
||||
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
|
||||
_attachmentUploads = _db.GetCollection<PendingAttachmentUpload>("attachment_uploads");
|
||||
_attachmentUploads.EnsureIndex(x => x.StorageId, unique: true);
|
||||
_queue.EnsureIndex(x => x.SequenceNr);
|
||||
_currentSeq = _meta.FindById("seq")?.Value ?? 0;
|
||||
}
|
||||
@@ -56,6 +59,18 @@ public class EventQueue : IDisposable
|
||||
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
|
||||
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
|
||||
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed);
|
||||
|
||||
// ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ────
|
||||
public void QueueAttachmentUpload(string storageId)
|
||||
{
|
||||
if (!_attachmentUploads.Exists(a => a.StorageId == storageId))
|
||||
_attachmentUploads.Insert(new PendingAttachmentUpload { StorageId = storageId });
|
||||
}
|
||||
public List<string> GetPendingAttachmentUploads() =>
|
||||
_attachmentUploads.FindAll().Select(a => a.StorageId).ToList();
|
||||
public void MarkAttachmentUploaded(string storageId) =>
|
||||
_attachmentUploads.DeleteMany(a => a.StorageId == storageId);
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
|
||||
@@ -75,3 +90,9 @@ internal class SyncMeta
|
||||
public long Value { get; set; }
|
||||
public DateTime? Timestamp { get; set; }
|
||||
}
|
||||
|
||||
internal class PendingAttachmentUpload
|
||||
{
|
||||
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
|
||||
public string StorageId { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public class SyncEngine : IDisposable
|
||||
private readonly EventQueue _queue;
|
||||
private readonly ConflictResolver _resolver;
|
||||
private readonly EventApplier _applier;
|
||||
private readonly AttachmentSyncer _attachments;
|
||||
private readonly HttpClient _http;
|
||||
private readonly SyncConfig _config;
|
||||
private readonly Timer _timer;
|
||||
@@ -20,13 +21,14 @@ public class SyncEngine : IDisposable
|
||||
public event Action<SyncStatus>? StatusChanged;
|
||||
|
||||
public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier,
|
||||
HttpClient http, SyncConfig config)
|
||||
AttachmentSyncer attachments, HttpClient http, SyncConfig config)
|
||||
{
|
||||
_queue = queue;
|
||||
_resolver = resolver;
|
||||
_applier = applier;
|
||||
_http = http;
|
||||
_config = config;
|
||||
_queue = queue;
|
||||
_resolver = resolver;
|
||||
_applier = applier;
|
||||
_attachments = attachments;
|
||||
_http = http;
|
||||
_config = config;
|
||||
_timer = new Timer(
|
||||
async _ => await SyncNowAsync(true), null,
|
||||
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes),
|
||||
@@ -42,6 +44,7 @@ public class SyncEngine : IDisposable
|
||||
try
|
||||
{
|
||||
var (pushed, _) = await PushAsync();
|
||||
await _attachments.UploadPendingAsync(_queue);
|
||||
var (pulled, conflicts) = await PullAsync();
|
||||
_queue.SetLastSyncAt(DateTime.UtcNow);
|
||||
SetState(SyncState.Idle);
|
||||
@@ -77,10 +80,10 @@ public class SyncEngine : IDisposable
|
||||
foreach (var evt in resp.Events)
|
||||
{
|
||||
var c = _resolver.TryResolve(evt, _config.DeviceId);
|
||||
if (c is null) { _applier.Apply(evt); continue; }
|
||||
if (c is null) { await _applier.ApplyAsync(evt); continue; }
|
||||
_queue.AddConflict(c);
|
||||
conflicts++;
|
||||
if (c.Resolution == "RemoteWon") _applier.Apply(evt);
|
||||
if (c.Resolution == "RemoteWon") await _applier.ApplyAsync(evt);
|
||||
}
|
||||
_queue.SetLastServerSeq(resp.ServerSequenceNr);
|
||||
return (resp.Events.Count, conflicts);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
@@ -15,5 +16,11 @@ public class SyncEventPublisher(EventQueue queue, string deviceId, byte[] syncKe
|
||||
{
|
||||
var encrypted = payload is null ? "" : SyncCrypto.EncryptObject(payload, syncKey);
|
||||
queue.Enqueue(deviceId, DeviceType.Desktop, entityType, entityId, operation, encrypted);
|
||||
|
||||
// Anhänge reisen nicht im JSON-Ereignis mit (würde den Kanal für Fotos/Scans aufblähen),
|
||||
// sondern als eigener Binärtransfer über AttachmentSyncer — hier nur zur Warteliste hinzufügen.
|
||||
if (payload is Documentation doc)
|
||||
foreach (var attachment in doc.Attachments)
|
||||
queue.QueueAttachmentUpload(attachment.StorageId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user