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.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using KronaStaffTerminal.Models;
namespace KronaStaffTerminal.Services; 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 sealed class AttendanceSyncService
{ {
private const string PilotHost = "192.168.1.81"; public async Task<AttendanceSyncResult> SyncPendingAsync(TerminalState state, CancellationToken cancellationToken = default)
private const int PilotPort = 8453;
public async Task SyncPendingAsync(TerminalState state, CancellationToken cancellationToken = default)
{ {
var result = new AttendanceSyncResult();
if (string.IsNullOrWhiteSpace(state.Settings.StaffServerToken) || 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 endpoint = BuildEndpoint(state.Settings.StaffServerUrl);
var expectedCertificate = NormalizeFingerprint(state.Settings.StaffServerCertificateSha256); var expectedCertificate = NormalizeFingerprint(state.Settings.StaffServerCertificateSha256);
using var handler = new HttpClientHandler { AllowAutoRedirect = false }; using var handler = new HttpClientHandler { AllowAutoRedirect = false };
handler.ServerCertificateCustomValidationCallback = (_, certificate, _, _) => if (expectedCertificate.Length == 64)
CertificateMatches(certificate, expectedCertificate); {
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(12) }; handler.ServerCertificateCustomValidationCallback = (_, certificate, _, _) =>
CertificateMatches(certificate, expectedCertificate);
}
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(15) };
foreach (var record in state.Attendance var pending = state.Attendance
.Where(item => !item.ServerSynced && item.EventType is "Приход" or "Уход") .Where(item => !item.ServerSynced && IsSyncableEvent(item.EventType))
.OrderBy(item => item.OccurredAtUtc)) .OrderBy(item => item.OccurredAtUtc)
.ToList();
result.ProcessedCount = pending.Count;
foreach (var record in pending)
{ {
try try
{ {
var isCheckIn = record.EventType == "Приход";
var isAuto = record.IsAutomatic || record.EventType.Contains("автоматически", StringComparison.OrdinalIgnoreCase);
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
request.Headers.Authorization = new AuthenticationHeaderValue( request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", "Bearer",
state.Settings.StaffServerToken); state.Settings.StaffServerToken);
request.Headers.Add("Idempotency-Key", record.Id);
request.Headers.Add("X-Request-Id", UlidGenerator.New());
request.Content = JsonContent.Create(new request.Content = JsonContent.Create(new
{ {
event_id = record.Id, event_id = record.Id,
employee_id = record.EmployeeId, 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, occurred_at = record.OccurredAtUtc,
source_event_id = record.Id, source_event_id = record.Id,
device_id = state.Settings.TerminalDeviceId device_id = state.Settings.TerminalDeviceId
}); });
using var response = await client.SendAsync(request, cancellationToken); 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}"; 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) 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 || root.Scheme != Uri.UriSchemeHttps ||
!string.Equals(root.Host, PilotHost, StringComparison.Ordinal) || string.IsNullOrWhiteSpace(root.Host) ||
root.Port != PilotPort ||
!string.IsNullOrEmpty(root.Query) || !string.IsNullOrEmpty(root.Query) ||
!string.IsNullOrEmpty(root.Fragment)) !string.IsNullOrEmpty(root.Fragment))
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
$"Пилот разрешает только https://{PilotHost}:{PilotPort} без перенаправлений."); "Требуется защищённый HTTPS-адрес сервера (например, https://staff.example.com:8453).");
} }
return new Uri(root, "/api/v1/staff/attendance/events"); 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)); var actual = Convert.ToHexString(SHA256.HashData(certificate.RawData));
return CryptographicOperations.FixedTimeEquals( return CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(actual), 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()); new(value.Where(Uri.IsHexDigit).Select(char.ToUpperInvariant).ToArray());
private static bool IsSyncableEvent(string eventType) =>
eventType is "Приход" or "Уход" || eventType.StartsWith("Уход", StringComparison.OrdinalIgnoreCase);
} }