krona-staff/apps/KronaStaffTerminal/Services/AttendanceSyncService.cs

142 lines
5.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 readonly HttpClient? _httpClient;
public AttendanceSyncService(HttpClient? httpClient = null)
{
_httpClient = httpClient;
}
public async Task<AttendanceSyncResult> 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 fallbackHandler = _httpClient == null ? new HttpClientHandler { AllowAutoRedirect = false } : null;
if (fallbackHandler != null && expectedCertificate.Length == 64)
{
fallbackHandler.ServerCertificateCustomValidationCallback = (_, certificate, _, _) =>
CertificateMatches(certificate, expectedCertificate);
}
using var fallbackClient = fallbackHandler != null ? new HttpClient(fallbackHandler) { Timeout = TimeSpan.FromSeconds(15) } : null;
var client = _httpClient ?? fallbackClient!;
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);
}