feat(sync): add configurable HTTPS endpoints, idempotency headers, and auto-checkout support

This commit is contained in:
Ochenstarik 2026-09-24 16:19:16 +07:00
parent 770ef00a28
commit c41bdd8654

View file

@ -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<AttendanceSyncResult> 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 };
if (expectedCertificate.Length == 64)
{
handler.ServerCertificateCustomValidationCallback = (_, certificate, _, _) =>
CertificateMatches(certificate, expectedCertificate);
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(12) };
}
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)
{
record.ServerSyncError = $"HTTP {(int)response.StatusCode}";
continue;
}
// 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 = "Нет защищённой связи с локальным сервером";
}
record.ServerSyncError = "Нет защищённой связи с сервером";
result.FailedCount++;
result.HasNetworkError = true;
result.LastError = exception.Message;
// Cease trying subsequent items in this cycle when host is unreachable
break;
}
}
private static Uri BuildEndpoint(string serverUrl)
return result;
}
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);
}