From c41bdd8654b9d9fe2edece317e62f4d9aef17666 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 16:19:16 +0700 Subject: [PATCH] feat(sync): add configurable HTTPS endpoints, idempotency headers, and auto-checkout support --- .../Services/AttendanceSyncService.cs | 100 +++++++++++++----- 1 file changed, 72 insertions(+), 28 deletions(-) diff --git a/apps/KronaStaffTerminal/Services/AttendanceSyncService.cs b/apps/KronaStaffTerminal/Services/AttendanceSyncService.cs index cfc10fd..7cac9d9 100644 --- a/apps/KronaStaffTerminal/Services/AttendanceSyncService.cs +++ b/apps/KronaStaffTerminal/Services/AttendanceSyncService.cs @@ -1,90 +1,134 @@ -using KronaStaffTerminal.Models; +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 { - private const string PilotHost = "192.168.1.81"; - private const int PilotPort = 8453; - - public async Task SyncPendingAsync(TerminalState state, CancellationToken cancellationToken = default) + public async Task SyncPendingAsync(TerminalState state, CancellationToken cancellationToken = default) { + var result = new AttendanceSyncResult(); if (string.IsNullOrWhiteSpace(state.Settings.StaffServerToken) || - string.IsNullOrWhiteSpace(state.Settings.StaffServerCertificateSha256)) + string.IsNullOrWhiteSpace(state.Settings.StaffServerUrl)) { - return; + return result; } var endpoint = BuildEndpoint(state.Settings.StaffServerUrl); var expectedCertificate = NormalizeFingerprint(state.Settings.StaffServerCertificateSha256); using var handler = new HttpClientHandler { AllowAutoRedirect = false }; - handler.ServerCertificateCustomValidationCallback = (_, certificate, _, _) => - CertificateMatches(certificate, expectedCertificate); - using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(12) }; + if (expectedCertificate.Length == 64) + { + handler.ServerCertificateCustomValidationCallback = (_, certificate, _, _) => + CertificateMatches(certificate, expectedCertificate); + } + using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(15) }; - foreach (var record in state.Attendance - .Where(item => !item.ServerSynced && item.EventType is "Приход" or "Уход") - .OrderBy(item => item.OccurredAtUtc)) + 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 = record.EventType == "Приход" ? "check_in" : "check_out", + 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) + 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}"; - continue; + result.FailedCount++; + result.LastError = record.ServerSyncError; } - record.ServerSynced = true; - record.ServerSyncError = null; } catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException) { - record.ServerSyncError = "Нет защищённой связи с локальным сервером"; + 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; } - private static Uri BuildEndpoint(string serverUrl) + public static Uri BuildEndpoint(string serverUrl) { - if (!Uri.TryCreate(serverUrl.Trim().TrimEnd('/'), UriKind.Absolute, out var root) || + if (string.IsNullOrWhiteSpace(serverUrl) || + !Uri.TryCreate(serverUrl.Trim().TrimEnd('/'), UriKind.Absolute, out var root) || root.Scheme != Uri.UriSchemeHttps || - !string.Equals(root.Host, PilotHost, StringComparison.Ordinal) || - root.Port != PilotPort || + string.IsNullOrWhiteSpace(root.Host) || !string.IsNullOrEmpty(root.Query) || !string.IsNullOrEmpty(root.Fragment)) { throw new InvalidOperationException( - $"Пилот разрешает только https://{PilotHost}:{PilotPort} без перенаправлений."); + "Требуется защищённый HTTPS-адрес сервера (например, https://staff.example.com:8453)."); } return new Uri(root, "/api/v1/staff/attendance/events"); } - private static bool CertificateMatches(X509Certificate2? certificate, string expectedFingerprint) + public static bool CertificateMatches(X509Certificate2? certificate, string expectedFingerprint) { - if (certificate is null || expectedFingerprint.Length != 64) return false; + 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(expectedFingerprint)); + Convert.FromHexString(normalized)); } - private static string NormalizeFingerprint(string value) => + 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); }