187 lines
6.5 KiB
C#
187 lines
6.5 KiB
C#
using System.Net.Http.Json;
|
||
using LehrerApp.Sync.Models;
|
||
|
||
namespace LehrerApp.Sync;
|
||
|
||
/// <summary>
|
||
/// Orchestriert Push, Pull, EventApply und Snapshot-Export.
|
||
/// Automatisch alle N Minuten + manuell auslösbar.
|
||
/// </summary>
|
||
public class SyncEngine : IDisposable
|
||
{
|
||
private readonly EventQueue _queue;
|
||
private readonly ConflictResolver _resolver;
|
||
private readonly EventApplier? _applier;
|
||
private readonly ReadableSnapshotService? _snapshotExport;
|
||
private readonly HttpClient _http;
|
||
private readonly SyncConfig _config;
|
||
private readonly Timer _timer;
|
||
|
||
public SyncStatus Status { get; private set; } = new();
|
||
public event Action<SyncStatus>? StatusChanged;
|
||
|
||
public SyncEngine(
|
||
EventQueue queue,
|
||
ConflictResolver resolver,
|
||
HttpClient http,
|
||
SyncConfig config,
|
||
EventApplier? applier = null,
|
||
ReadableSnapshotService? snapshotExport = null)
|
||
{
|
||
_queue = queue;
|
||
_resolver = resolver;
|
||
_http = http;
|
||
_config = config;
|
||
_applier = applier;
|
||
_snapshotExport = snapshotExport;
|
||
|
||
_timer = new Timer(
|
||
async _ => await SyncNowAsync(isAutomatic: true),
|
||
null,
|
||
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes),
|
||
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes));
|
||
|
||
UpdateStatus();
|
||
}
|
||
|
||
// ── Öffentliche API ───────────────────────────────────────────────────────
|
||
|
||
public async Task<SyncResult> SyncNowAsync(bool isAutomatic = false)
|
||
{
|
||
if (Status.State == SyncState.Syncing)
|
||
return new SyncResult { Skipped = true, Reason = "Sync bereits aktiv" };
|
||
|
||
SetState(SyncState.Syncing);
|
||
|
||
try
|
||
{
|
||
// 1. Ausstehende Events pushen
|
||
var pushResult = await PushAsync();
|
||
|
||
// 2. Neue Events vom Server holen
|
||
var pullResult = await PullAsync();
|
||
|
||
// 3. Geholte Events auf LiteDB anwenden
|
||
if (_applier is not null && pullResult.Events.Count > 0)
|
||
_applier.ApplyAll(pullResult.Events);
|
||
|
||
// 4. Nach erfolgreichem Sync: lesbaren Snapshot exportieren
|
||
// (nur bei automatischem Sync oder explizit – nicht bei jedem
|
||
// manuellen Push um Traffic zu sparen)
|
||
if (_snapshotExport is not null && (isAutomatic || pushResult.Pushed > 0))
|
||
await _snapshotExport.ExportAndPushAsync();
|
||
|
||
_queue.SetLastSyncAt(DateTime.UtcNow);
|
||
SetState(SyncState.Idle);
|
||
|
||
return new SyncResult
|
||
{
|
||
Success = true,
|
||
EventsPushed = pushResult.Pushed,
|
||
EventsPulled = pullResult.Events.Count,
|
||
Conflicts = pullResult.Conflicts,
|
||
};
|
||
}
|
||
catch (HttpRequestException)
|
||
{
|
||
SetState(SyncState.Offline);
|
||
return new SyncResult { Success = false, Reason = "Server nicht erreichbar" };
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetState(SyncState.Error, ex.Message);
|
||
return new SyncResult { Success = false, Reason = ex.Message };
|
||
}
|
||
}
|
||
|
||
// ── Push ──────────────────────────────────────────────────────────────────
|
||
|
||
private async Task<(int Pushed, int Conflicts)> PushAsync()
|
||
{
|
||
var pending = _queue.GetPending(maxBatch: 200);
|
||
if (pending.Count == 0) return (0, 0);
|
||
|
||
var response = await _http.PostAsJsonAsync("/api/sync/push", pending);
|
||
response.EnsureSuccessStatusCode();
|
||
|
||
var result = await response.Content.ReadFromJsonAsync<PushResponse>();
|
||
if (result is null) return (0, 0);
|
||
|
||
var sentIds = pending
|
||
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
|
||
.Select(e => e.EventId);
|
||
_queue.Acknowledge(sentIds);
|
||
_queue.SetLastServerSequenceNr(result.ServerSequenceNr);
|
||
|
||
return (pending.Count - result.ConflictingEventIds.Count,
|
||
result.ConflictingEventIds.Count);
|
||
}
|
||
|
||
// ── Pull ──────────────────────────────────────────────────────────────────
|
||
|
||
private async Task<(List<SyncEvent> Events, int Conflicts)> PullAsync()
|
||
{
|
||
var since = _queue.GetLastServerSequenceNr();
|
||
var response = await _http.GetFromJsonAsync<PullResponse>(
|
||
$"/api/sync/pull?since={since}&deviceId={_config.DeviceId}");
|
||
|
||
if (response is null || response.Events.Count == 0)
|
||
return ([], 0);
|
||
|
||
var conflicts = 0;
|
||
foreach (var remoteEvent in response.Events)
|
||
{
|
||
var conflict = _resolver.TryResolve(remoteEvent, _config.DeviceId);
|
||
if (conflict is not null)
|
||
{
|
||
_queue.AddConflict(conflict);
|
||
conflicts++;
|
||
}
|
||
}
|
||
|
||
_queue.SetLastServerSequenceNr(response.ServerSequenceNr);
|
||
return (response.Events, conflicts);
|
||
}
|
||
|
||
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||
|
||
private void SetState(SyncState state, string? error = null)
|
||
{
|
||
Status = new SyncStatus
|
||
{
|
||
State = state,
|
||
LastSyncAt = _queue.GetLastSyncAt(),
|
||
PendingEvents = _queue.PendingCount(),
|
||
ConflictCount = _queue.ConflictCount(),
|
||
ErrorMessage = error,
|
||
};
|
||
StatusChanged?.Invoke(Status);
|
||
}
|
||
|
||
private void UpdateStatus() => SetState(Status.State);
|
||
|
||
public void Dispose()
|
||
{
|
||
_timer.Dispose();
|
||
_queue.Dispose();
|
||
}
|
||
}
|
||
|
||
public class SyncConfig
|
||
{
|
||
public string ServerUrl { get; set; } = "";
|
||
public string DeviceId { get; set; } = "";
|
||
public DeviceType DeviceType { get; set; } = DeviceType.Desktop;
|
||
public int AutoSyncIntervalMinutes { get; set; } = 5;
|
||
}
|
||
|
||
public class SyncResult
|
||
{
|
||
public bool Success { get; set; }
|
||
public bool Skipped { get; set; }
|
||
public string? Reason { get; set; }
|
||
public int EventsPushed { get; set; }
|
||
public int EventsPulled { get; set; }
|
||
public int Conflicts { get; set; }
|
||
}
|