using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using KronaStaffTerminal.Models; namespace KronaStaffTerminal.Services; public sealed class AttendanceSyncResult { public int ProcessedCount { get; set; } public int SuccessCount { get; set; } public int FailedCount { get; set; } public string? LastError { get; set; } public bool HasNetworkError { get; set; } } public sealed class AttendanceSyncService { public async Task SyncPendingAsync(TerminalState state, CancellationToken cancellationToken = default) { var result = new AttendanceSyncResult(); if (string.IsNullOrWhiteSpace(state.Settings.StaffServerToken) || string.IsNullOrWhiteSpace(state.Settings.StaffServerUrl)) { return result; } var endpoint = BuildEndpoint(state.Settings.StaffServerUrl); var expectedCertificate = NormalizeFingerprint(state.Settings.StaffServerCertificateSha256); using var handler = new HttpClientHandler { AllowAutoRedirect = false }; if (expectedCertificate.Length == 64) { handler.ServerCertificateCustomValidationCallback = (_, certificate, _, _) => CertificateMatches(certificate, expectedCertificate); } using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(15) }; var pending = state.Attendance .Where(item => !item.ServerSynced && IsSyncableEvent(item.EventType)) .OrderBy(item => item.OccurredAtUtc) .ToList(); result.ProcessedCount = pending.Count; foreach (var record in pending) { try { var isCheckIn = record.EventType == "Приход"; var isAuto = record.IsAutomatic || record.EventType.Contains("автоматически", StringComparison.OrdinalIgnoreCase); using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", state.Settings.StaffServerToken); request.Headers.Add("Idempotency-Key", record.Id); request.Headers.Add("X-Request-Id", UlidGenerator.New()); request.Content = JsonContent.Create(new { event_id = record.Id, employee_id = record.EmployeeId, event_type = isCheckIn ? "check_in" : "check_out", is_automatic = isAuto, trigger = isAuto ? "auto_schedule" : "manual_biometric", occurred_at = record.OccurredAtUtc, source_event_id = record.Id, device_id = state.Settings.TerminalDeviceId }); using var response = await client.SendAsync(request, cancellationToken); if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.Conflict) { // 200/201 Success, or 409 Conflict (idempotent duplicate already received on server) record.ServerSynced = true; record.ServerSyncError = null; result.SuccessCount++; } else { record.ServerSyncError = $"HTTP {(int)response.StatusCode}"; result.FailedCount++; result.LastError = record.ServerSyncError; } } catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException) { record.ServerSyncError = "Нет защищённой связи с сервером"; result.FailedCount++; result.HasNetworkError = true; result.LastError = exception.Message; // Cease trying subsequent items in this cycle when host is unreachable break; } } return result; } public static Uri BuildEndpoint(string serverUrl) { if (string.IsNullOrWhiteSpace(serverUrl) || !Uri.TryCreate(serverUrl.Trim().TrimEnd('/'), UriKind.Absolute, out var root) || root.Scheme != Uri.UriSchemeHttps || string.IsNullOrWhiteSpace(root.Host) || !string.IsNullOrEmpty(root.Query) || !string.IsNullOrEmpty(root.Fragment)) { throw new InvalidOperationException( "Требуется защищённый HTTPS-адрес сервера (например, https://staff.example.com:8453)."); } return new Uri(root, "/api/v1/staff/attendance/events"); } public static bool CertificateMatches(X509Certificate2? certificate, string expectedFingerprint) { if (certificate is null) return false; var normalized = NormalizeFingerprint(expectedFingerprint); if (normalized.Length != 64) return false; var actual = Convert.ToHexString(SHA256.HashData(certificate.RawData)); return CryptographicOperations.FixedTimeEquals( Convert.FromHexString(actual), Convert.FromHexString(normalized)); } public static string NormalizeFingerprint(string value) => new(value.Where(Uri.IsHexDigit).Select(char.ToUpperInvariant).ToArray()); private static bool IsSyncableEvent(string eventType) => eventType is "Приход" or "Уход" || eventType.StartsWith("Уход", StringComparison.OrdinalIgnoreCase); }