142 lines
5.9 KiB
C#
142 lines
5.9 KiB
C#
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);
|
||
}
|