feat(sync): add configurable HTTPS endpoints, idempotency headers, and auto-checkout support
This commit is contained in:
parent
770ef00a28
commit
c41bdd8654
1 changed files with 72 additions and 28 deletions
|
|
@ -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 };
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue