diff --git a/.gitignore b/.gitignore index 619fe86..9d8783f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,9 @@ backups/ restore-work/ apps/KronaStaffTerminal/bin/ apps/KronaStaffTerminal/obj/ +apps/KronaStaffService/bin/ +apps/KronaStaffService/obj/ +apps/KronaStaffTerminal.Tests/bin/ +apps/KronaStaffTerminal.Tests/obj/ artifacts/ .audit-build/ diff --git a/apps/KronaStaffService/KronaStaffService.csproj b/apps/KronaStaffService/KronaStaffService.csproj new file mode 100644 index 0000000..2027942 --- /dev/null +++ b/apps/KronaStaffService/KronaStaffService.csproj @@ -0,0 +1,43 @@ + + + + net10.0-windows10.0.26100.0 + enable + enable + x64 + x64 + win-x64 + KronaStaffService + 0.2.5 + 0.2.5.0 + 0.2.5.0 + + + + + + + + + + + + ..\KronaStaffTerminal\Vendor\ZKFinger\x64\libzkfpcsharp.dll + true + + + + + + + + + + + + + + + + + diff --git a/apps/KronaStaffService/KronaStaffWorker.cs b/apps/KronaStaffService/KronaStaffWorker.cs new file mode 100644 index 0000000..3fd8706 --- /dev/null +++ b/apps/KronaStaffService/KronaStaffWorker.cs @@ -0,0 +1,245 @@ +using System.Text.Json; +using KronaStaffTerminal.Models; +using KronaStaffTerminal.Services; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace KronaStaffService; + +public sealed class KronaStaffWorker : BackgroundService +{ + private readonly ILogger _logger; + private readonly TerminalStore _store; + private readonly AttendanceSyncService _syncService; + private readonly ILicenseStatusPort _licensePort; + private readonly SemaphoreSlim _syncGate = new(1, 1); + private TerminalIpcServer? _ipcServer; + private bool _scannerSession0Supported; + private string _scannerDiagnostic = ""; + private DateTimeOffset? _lastSyncUtc; + private string? _lastSyncError; + + public KronaStaffWorker( + ILogger logger, + TerminalStore store, + AttendanceSyncService syncService, + ILicenseStatusPort licensePort) + { + _logger = logger; + _store = store; + _syncService = syncService; + _licensePort = licensePort; + } + + public override Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Krona Staff Service starting..."); + CrashLogService.WriteDiagnostic("Krona Staff Windows Service starting."); + + // Probe whether ZKFinger SDK supports device capture in Session 0 + _scannerSession0Supported = FingerprintScanner.ProbeSession0Support(out _scannerDiagnostic); + _logger.LogInformation("Session 0 scanner probe: Supported={Supported}, Diagnostic='{Diag}'", + _scannerSession0Supported, _scannerDiagnostic); + CrashLogService.WriteDiagnostic($"Session 0 scanner probe: {_scannerDiagnostic}"); + + // Initialize IPC server + _ipcServer = new TerminalIpcServer(HandleIpcRequestAsync); + _ipcServer.Start(); + _logger.LogInformation("IPC Server listening on pipe {PipeName}", TerminalIpcConstants.PipeName); + + return base.StartAsync(cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Background attendance sync worker loop started."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await RunSyncCycleAsync(stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error in background sync loop."); + CrashLogService.Write(ex); + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + + _logger.LogInformation("Background attendance sync worker loop stopped."); + } + + public async Task RunSyncCycleAsync(CancellationToken cancellationToken) + { + await _syncGate.WaitAsync(cancellationToken); + try + { + TerminalState state; + try + { + state = await _store.LoadAsync(); + } + catch (Exception ex) + { + _lastSyncError = $"Не удалось прочитать состояние: {ex.Message}"; + _logger.LogWarning(ex, "Could not load terminal state for sync."); + return new AttendanceSyncResult { LastError = _lastSyncError }; + } + + var hasPending = state.Attendance.Any(a => !a.ServerSynced); + if (!hasPending) + { + return new AttendanceSyncResult(); + } + + var result = await _syncService.SyncPendingAsync(state, cancellationToken); + if (result.SuccessCount > 0) + { + await _store.SaveAsync(state); + _lastSyncUtc = DateTimeOffset.UtcNow; + _lastSyncError = null; + _logger.LogInformation("Synced {Count} attendance record(s) to server.", result.SuccessCount); + } + if (result.FailedCount > 0) + { + _lastSyncError = result.LastError; + _logger.LogWarning("Failed to sync {Count} record(s): {Error}", result.FailedCount, result.LastError); + } + + return result; + } + finally + { + _syncGate.Release(); + } + } + + private async Task HandleIpcRequestAsync(IpcRequest request) + { + try + { + switch (request.Command) + { + case "Ping": + return new IpcResponse + { + Success = true, + Status = await GetCurrentStatusAsync() + }; + + case "GetStatus": + return new IpcResponse + { + Success = true, + Status = await GetCurrentStatusAsync() + }; + + case "GetLicenseStatus": + return new IpcResponse + { + Success = true, + Status = new IpcServiceStatus + { + LicenseStatus = _licensePort.GetStatus().ToString() + } + }; + + case "EnqueueAttendance": + if (string.IsNullOrWhiteSpace(request.Payload)) + { + return new IpcResponse { Success = false, ErrorMessage = "Missing attendance payload" }; + } + var record = JsonSerializer.Deserialize(request.Payload); + if (record is null) + { + return new IpcResponse { Success = false, ErrorMessage = "Invalid attendance record JSON" }; + } + + await _syncGate.WaitAsync(); + try + { + var state = await _store.LoadAsync(); + if (state.Attendance.All(a => a.Id != record.Id)) + { + state.Attendance.Insert(0, record); + await _store.SaveAsync(state); + } + } + finally + { + _syncGate.Release(); + } + + // Trigger immediate sync + _ = Task.Run(() => RunSyncCycleAsync(CancellationToken.None)); + + return new IpcResponse { Success = true }; + + case "TriggerSync": + var syncResult = await RunSyncCycleAsync(CancellationToken.None); + return new IpcResponse + { + Success = true, + SyncedCount = syncResult.SuccessCount, + Payload = JsonSerializer.Serialize(syncResult) + }; + + default: + return new IpcResponse { Success = false, ErrorMessage = $"Unknown command: {request.Command}" }; + } + } + catch (Exception ex) + { + CrashLogService.Write(ex); + return new IpcResponse { Success = false, ErrorMessage = ex.Message }; + } + } + + private async Task GetCurrentStatusAsync() + { + var pendingCount = 0; + try + { + var state = await _store.LoadAsync(); + pendingCount = state.Attendance.Count(a => !a.ServerSynced); + } + catch + { + // ignore if unable to read count + } + + return new IpcServiceStatus + { + ServiceRunning = true, + ServiceVersion = "0.2.5", + ScannerInSession0Supported = _scannerSession0Supported, + ScannerDiagnostic = _scannerDiagnostic, + PendingSyncCount = pendingCount, + LastSyncUtc = _lastSyncUtc, + LastSyncError = _lastSyncError, + LicenseStatus = _licensePort.GetStatus().ToString() + }; + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Krona Staff Service stopping..."); + _ipcServer?.Dispose(); + CrashLogService.WriteDiagnostic("Krona Staff Windows Service stopped."); + return base.StopAsync(cancellationToken); + } +} diff --git a/apps/KronaStaffService/Program.cs b/apps/KronaStaffService/Program.cs new file mode 100644 index 0000000..cf15b4d --- /dev/null +++ b/apps/KronaStaffService/Program.cs @@ -0,0 +1,26 @@ +using KronaStaffService; +using KronaStaffTerminal.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.AddWindowsService(options => +{ + options.ServiceName = "KronaStaffService"; +}); + +builder.Logging.AddSimpleConsole(options => +{ + options.IncludeScopes = false; + options.TimestampFormat = "[yyyy-MM-dd HH:mm:ss] "; +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +var host = builder.Build(); +host.Run(); diff --git a/apps/KronaStaffService/README.md b/apps/KronaStaffService/README.md new file mode 100644 index 0000000..892709b --- /dev/null +++ b/apps/KronaStaffService/README.md @@ -0,0 +1,39 @@ +# Krona Staff Service (Windows Service) + +Фоновая системная служба Windows для Krona Staff Terminal. + +## Назначение + +- Фоновая отправка очереди отметок посещаемости (`check_in`, `check_out`, автоматический уход) на сервер Staff API через HTTPS. +- Непрерывная синхронизация очереди даже после выхода интерактивного пользователя из Windows (Log off / Switch user). +- Идемпотентная доставка событий с сохранением `Idempotency-Key` (ULID) и предотвращением дубликатов при сетевых сбоях. +- Предоставление защищённого локального IPC-канала (Named Pipe `\\.\pipe\KronaStaffTerminalPipe`) для взаимодействия с пользовательским интерфейсом `KronaStaffTerminal`. +- Проверка и публикация статуса лицензии через `ILicenseStatusPort`. + +## Архитектура и изоляция Session 0 + +В операционных системах Windows (начиная с Windows Vista / Server 2008 и включая Windows 10/11) службы выполняются в изолированной сессии **Session 0** без интерактивного рабочего стола. + +### Результаты анализа ZKFinger SDK 5.3 и драйвера SLK20R в Session 0: +1. Пользовательская библиотека `libzkfp.dll` и драйвер `slk20r.sys` при инициализации используют оконные сообщения Win32 (`WM_DEVICECHANGE`) и оконные дескрипторы для отслеживания PnP-событий USB, а также дескрипторы интерфейсов устройств, привязанные к интерактивным пользовательским сессиям. +2. При вызове `zkfp2.Init()` в Session 0 драйвер сообщает 0 подключённых устройств (`GetDeviceCount() == 0`) либо завершается с кодом ошибки дескриптора (-1 / -7). +3. Попытка эксклюзивного захвата USB-устройства службой Session 0 блокирует доступ к сканеру со стороны интерактивного пользовательского сеанса для регистрации новых отпечатков. + +### Принятое решение: +- **KronaStaffService** работает как системная служба Windows и берет на себя непрерывную синхронизацию очереди отметок, хранение состояния в `%ProgramData%\Krona\StaffTerminal` под защитой Windows DPAPI (`DataProtectionScope.LocalMachine`) и IPC-шлюз. +- Интерактивное приложение **KronaStaffTerminal** подключается к сканеру SLK20R в пользовательской сессии (с автозапуском при входе в систему и сворачиванием в трей), выполняет высокоточное распознавание и регистрацию отпечатков, фиксирует отметки и передаёт их в очередь службы. +- Если интерактивный пользователь выходит из системы или закрывает приложение, служба **KronaStaffService** продолжает выгрузку всех накопленных офлайн-событий на сервер. + +## Управление службой + +Установка и регистрация службы: +```powershell +sc.exe create KronaStaffService binPath= "C:\Program Files\Krona Staff Terminal\KronaStaffService.exe" start= auto obj= "NT AUTHORITY\SYSTEM" DisplayName= "Krona Staff Service" +sc.exe start KronaStaffService +``` + +Остановка и удаление: +```powershell +sc.exe stop KronaStaffService +sc.exe delete KronaStaffService +``` diff --git a/apps/KronaStaffTerminal.Tests/AdminPinServiceTests.cs b/apps/KronaStaffTerminal.Tests/AdminPinServiceTests.cs new file mode 100644 index 0000000..7f5ad17 --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/AdminPinServiceTests.cs @@ -0,0 +1,124 @@ +using System.Security.Cryptography; +using System.Text; +using KronaStaffTerminal.Models; +using KronaStaffTerminal.Services; +using Xunit; + +namespace KronaStaffTerminal.Tests; + +public class AdminPinServiceTests +{ + [Fact] + public void HashAndVerify_ValidPin_ReturnsTrue() + { + var pin = "1234"; + var (salt, hash, iterations) = AdminPinService.Hash(pin); + + Assert.NotNull(salt); + Assert.StartsWith(AdminPinService.Argon2Prefix, hash); + Assert.True(iterations >= 1); + + var verified = AdminPinService.Verify(pin, salt, hash, iterations); + Assert.True(verified); + } + + [Fact] + public void Verify_InvalidPin_ReturnsFalse() + { + var (salt, hash, iterations) = AdminPinService.Hash("5678"); + + var verified = AdminPinService.Verify("0000", salt, hash, iterations); + Assert.False(verified); + } + + [Fact] + public void Verify_LegacyPbkdf2Hash_VerifiesSuccessfully() + { + var pin = "9876"; + var salt = RandomNumberGenerator.GetBytes(16); + var saltBase64 = Convert.ToBase64String(salt); + var legacyIterations = 120_000; + var hashBytes = Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(pin), + salt, + legacyIterations, + HashAlgorithmName.SHA256, + 32); + var hashBase64 = Convert.ToBase64String(hashBytes); + + var verified = AdminPinService.Verify(pin, saltBase64, hashBase64, legacyIterations); + Assert.True(verified); + } + + [Fact] + public void VerifyAndRecord_LegacyHashUpgradesToArgon2id() + { + var pin = "4321"; + var salt = RandomNumberGenerator.GetBytes(16); + var legacyIterations = 120_000; + var hashBytes = Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(pin), + salt, + legacyIterations, + HashAlgorithmName.SHA256, + 32); + + var settings = new TerminalSettings + { + AdminPinSalt = Convert.ToBase64String(salt), + AdminPinHash = Convert.ToBase64String(hashBytes), + AdminPinIterations = legacyIterations, + FailedAdminPinAttempts = 0 + }; + + var now = DateTimeOffset.UtcNow; + var success = AdminPinService.VerifyAndRecord(pin, settings, now, out var error, out var rehashNeeded); + + Assert.True(success); + Assert.Null(error); + Assert.True(rehashNeeded); + Assert.StartsWith(AdminPinService.Argon2Prefix, settings.AdminPinHash); + } + + [Fact] + public void VerifyAndRecord_ThreeFailedAttempts_TriggersLockout() + { + var (salt, hash, iterations) = AdminPinService.Hash("1111"); + var settings = new TerminalSettings + { + AdminPinSalt = salt, + AdminPinHash = hash, + AdminPinIterations = iterations, + FailedAdminPinAttempts = 0 + }; + + var now = DateTimeOffset.UtcNow; + + // Attempt 1: Fail + var r1 = AdminPinService.VerifyAndRecord("0000", settings, now, out var err1, out _); + Assert.False(r1); + Assert.Contains("до блокировки: 2", err1); + + // Attempt 2: Fail + var r2 = AdminPinService.VerifyAndRecord("0000", settings, now, out var err2, out _); + Assert.False(r2); + Assert.Contains("до блокировки: 1", err2); + + // Attempt 3: Lockout triggered (30s) + var r3 = AdminPinService.VerifyAndRecord("0000", settings, now, out var err3, out _); + Assert.False(r3); + Assert.Contains("заблокирован на 30 сек", err3); + Assert.True(settings.AdminPinBlockedUntilUtc > now); + + // Attempt 4: Even with correct PIN, blocked during lockout + var r4 = AdminPinService.VerifyAndRecord("1111", settings, now.AddSeconds(5), out var err4, out _); + Assert.False(r4); + Assert.Contains("временно заблокирован", err4); + + // Attempt 5: After lockout expires, correct PIN succeeds + var r5 = AdminPinService.VerifyAndRecord("1111", settings, now.AddSeconds(35), out var err5, out _); + Assert.True(r5); + Assert.Null(err5); + Assert.Equal(0, settings.FailedAdminPinAttempts); + } +} diff --git a/apps/KronaStaffTerminal.Tests/AttendanceSyncTests.cs b/apps/KronaStaffTerminal.Tests/AttendanceSyncTests.cs new file mode 100644 index 0000000..d4ff771 --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/AttendanceSyncTests.cs @@ -0,0 +1,186 @@ +using System.Net; +using KronaStaffTerminal.Models; +using KronaStaffTerminal.Services; +using Xunit; + +namespace KronaStaffTerminal.Tests; + +public class AttendanceSyncTests +{ + private sealed class MockHttpMessageHandler : HttpMessageHandler + { + public Func HandlerFunc { get; set; } = + _ => new HttpResponseMessage(HttpStatusCode.OK); + + public List CapturedRequests { get; } = new(); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + CapturedRequests.Add(request); + return Task.FromResult(HandlerFunc(request)); + } + } + + private static TerminalState CreateConfiguredState() + { + return new TerminalState + { + Settings = new TerminalSettings + { + StaffServerUrl = "https://staff.krona.internal:8453", + StaffServerToken = "test_token_secret_123", + TerminalDeviceId = "TERM-TEST-01" + } + }; + } + + [Fact] + public async Task SyncPending_With200Ok_MarksRecordsAsSyncedAndSendsHeaders() + { + var mockHandler = new MockHttpMessageHandler + { + HandlerFunc = _ => new HttpResponseMessage(HttpStatusCode.OK) + }; + var httpClient = new HttpClient(mockHandler); + var syncService = new AttendanceSyncService(httpClient); + var state = CreateConfiguredState(); + + var record1 = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = "emp-10", + EventType = "Приход", + OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-5), + ServerSynced = false + }; + var record2 = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = "emp-10", + EventType = "Уход", + OccurredAtUtc = DateTimeOffset.UtcNow, + ServerSynced = false + }; + + state.Attendance.Add(record1); + state.Attendance.Add(record2); + + var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken); + + Assert.Equal(2, result.ProcessedCount); + Assert.Equal(2, result.SuccessCount); + Assert.Equal(0, result.FailedCount); + Assert.True(record1.ServerSynced); + Assert.True(record2.ServerSynced); + + // Verify headers + Assert.Equal(2, mockHandler.CapturedRequests.Count); + var req = mockHandler.CapturedRequests[0]; + Assert.Equal("Bearer test_token_secret_123", req.Headers.Authorization?.ToString()); + Assert.True(req.Headers.Contains("Idempotency-Key")); + Assert.True(req.Headers.Contains("X-Request-Id")); + } + + [Fact] + public async Task SyncPending_With409Conflict_TreatedAsIdempotentSuccess() + { + var mockHandler = new MockHttpMessageHandler + { + HandlerFunc = _ => new HttpResponseMessage(HttpStatusCode.Conflict) + }; + var httpClient = new HttpClient(mockHandler); + var syncService = new AttendanceSyncService(httpClient); + var state = CreateConfiguredState(); + + var record = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = "emp-15", + EventType = "Приход", + OccurredAtUtc = DateTimeOffset.UtcNow, + ServerSynced = false + }; + state.Attendance.Add(record); + + var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken); + + Assert.Equal(1, result.ProcessedCount); + Assert.Equal(1, result.SuccessCount); + Assert.Equal(0, result.FailedCount); + Assert.True(record.ServerSynced); + Assert.Null(record.ServerSyncError); + } + + [Fact] + public async Task SyncPending_With500Error_RetainsRecordForRetry() + { + var mockHandler = new MockHttpMessageHandler + { + HandlerFunc = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError) + }; + var httpClient = new HttpClient(mockHandler); + var syncService = new AttendanceSyncService(httpClient); + var state = CreateConfiguredState(); + + var record = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = "emp-20", + EventType = "Приход", + OccurredAtUtc = DateTimeOffset.UtcNow, + ServerSynced = false + }; + state.Attendance.Add(record); + + var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken); + + Assert.Equal(1, result.ProcessedCount); + Assert.Equal(0, result.SuccessCount); + Assert.Equal(1, result.FailedCount); + Assert.False(record.ServerSynced); + Assert.Equal("HTTP 500", record.ServerSyncError); + } + + [Fact] + public async Task SyncPending_WithNetworkException_HaltsCurrentCycle() + { + var mockHandler = new MockHttpMessageHandler + { + HandlerFunc = _ => throw new HttpRequestException("Connection refused") + }; + var httpClient = new HttpClient(mockHandler); + var syncService = new AttendanceSyncService(httpClient); + var state = CreateConfiguredState(); + + var record1 = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = "emp-1", + EventType = "Приход", + OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-2), + ServerSynced = false + }; + var record2 = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = "emp-2", + EventType = "Приход", + OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1), + ServerSynced = false + }; + state.Attendance.Add(record1); + state.Attendance.Add(record2); + + var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken); + + Assert.Equal(2, result.ProcessedCount); + Assert.Equal(1, result.FailedCount); + Assert.True(result.HasNetworkError); + Assert.False(record1.ServerSynced); + Assert.False(record2.ServerSynced); + // Only first was attempted because loop broke on network drop + Assert.Single(mockHandler.CapturedRequests); + } +} diff --git a/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs b/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs new file mode 100644 index 0000000..1deb238 --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs @@ -0,0 +1,223 @@ +using KronaStaffTerminal.Models; +using KronaStaffTerminal.Services; +using Xunit; + +namespace KronaStaffTerminal.Tests; + +public class AttendanceTrackerTests +{ + private static (TerminalState, EmployeeRecord) CreateTestState() + { + var emp = new EmployeeRecord + { + Id = "emp-1", + DisplayName = "Тестовый Сотрудник", + FingerprintId = 1 + }; + + var state = new TerminalState + { + Employees = { emp }, + Settings = new TerminalSettings + { + DuplicateScanGuardSeconds = 30, + MaximumShiftHours = 14, + AutomaticCheckoutEnabled = true, + AutomaticCheckoutHour = 23, + AutomaticCheckoutMinute = 0 + } + }; + + return (state, emp); + } + + [Fact] + public async Task TryRecordAttendance_FirstScan_RecordsCheckIn() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + var result = await tracker.TryRecordAttendanceAsync( + state, emp, 0, now, _ => Task.CompletedTask); + + Assert.True(result.Success); + Assert.False(result.IsDuplicateSuppressed); + Assert.NotNull(result.Record); + Assert.Equal("Приход", result.Record.EventType); + Assert.Equal(emp.Id, result.Record.EmployeeId); + Assert.False(result.Record.IsAutomatic); + Assert.Single(state.Attendance); + } + + [Fact] + public async Task TryRecordAttendance_ImmediateSecondScan_RejectedAsDuplicate() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + // First scan + var first = await tracker.TryRecordAttendanceAsync( + state, emp, 0, now, _ => Task.CompletedTask); + Assert.True(first.Success); + + // Immediate second scan 5 seconds later (guard is 30s) + var second = await tracker.TryRecordAttendanceAsync( + state, emp, 0, now.AddSeconds(5), _ => Task.CompletedTask); + + Assert.False(second.Success); + Assert.True(second.IsDuplicateSuppressed); + Assert.Null(second.Record); + Assert.Equal(25, second.RemainingSeconds); + Assert.Contains("Повторный скан отклонён", second.Message); + Assert.Single(state.Attendance); // No extra record inserted + } + + [Fact] + public async Task TryRecordAttendance_SecondScanAfterGuard_RecordsCheckOut() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + // Check in + await tracker.TryRecordAttendanceAsync( + state, emp, 0, now, _ => Task.CompletedTask); + + // Check out after 35 seconds (guard is 30s) + var second = await tracker.TryRecordAttendanceAsync( + state, emp, 0, now.AddSeconds(35), _ => Task.CompletedTask); + + Assert.True(second.Success); + Assert.False(second.IsDuplicateSuppressed); + Assert.NotNull(second.Record); + Assert.Equal("Уход", second.Record.EventType); + Assert.Equal(2, state.Attendance.Count); + } + + [Fact] + public async Task TryRecordAttendance_DifferentEmployee_NotSuppressed() + { + var (state, emp1) = CreateTestState(); + var emp2 = new EmployeeRecord + { + Id = "emp-2", + DisplayName = "Второй Сотрудник", + FingerprintId = 2 + }; + state.Employees.Add(emp2); + + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + // Scan emp 1 + var r1 = await tracker.TryRecordAttendanceAsync( + state, emp1, 0, now, _ => Task.CompletedTask); + Assert.True(r1.Success); + + // Scan emp 2 1 second later + var r2 = await tracker.TryRecordAttendanceAsync( + state, emp2, 0, now.AddSeconds(1), _ => Task.CompletedTask); + Assert.True(r2.Success); + Assert.False(r2.IsDuplicateSuppressed); + Assert.Equal(2, state.Attendance.Count); + } + + [Fact] + public async Task Concurrency_FiftyParallelScans_OnlyOneSucceeds() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + var saveCount = 0; + Task SaveCallback(TerminalState s) + { + Interlocked.Increment(ref saveCount); + return Task.CompletedTask; + } + + // Launch 50 concurrent tasks at the exact same moment + var tasks = Enumerable.Range(0, 50).Select(_ => + tracker.TryRecordAttendanceAsync(state, emp, 0, now, SaveCallback) + ).ToArray(); + + var results = await Task.WhenAll(tasks); + + var successCount = results.Count(r => r.Success); + var suppressedCount = results.Count(r => r.IsDuplicateSuppressed); + + Assert.Equal(1, successCount); + Assert.Equal(49, suppressedCount); + Assert.Equal(1, saveCount); + Assert.Single(state.Attendance); + } + + [Fact] + public async Task RunScheduledCheckout_AutoChecksOutOpenShifts() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + + // Employee checked in at 09:00 UTC + var checkInTime = new DateTimeOffset(2026, 9, 24, 9, 0, 0, TimeSpan.Zero); + state.Attendance.Add(new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = emp.Id, + EmployeeName = emp.DisplayName, + OccurredAtUtc = checkInTime, + EventType = "Приход", + IsAutomatic = false + }); + + // Current time is 23:30 (checkout scheduled for 23:00) + var nowUtc = new DateTimeOffset(2026, 9, 24, 23, 30, 0, TimeSpan.Zero); + + var checkedOutRecords = await tracker.RunScheduledCheckoutAsync( + state, nowUtc, _ => Task.CompletedTask); + + Assert.Single(checkedOutRecords); + var autoRecord = checkedOutRecords[0]; + Assert.Equal("Уход автоматически", autoRecord.EventType); + Assert.True(autoRecord.IsAutomatic); + Assert.Equal(emp.Id, autoRecord.EmployeeId); + Assert.Equal(2, state.Attendance.Count); + } + + [Fact] + public async Task RunScheduledCheckout_AlreadyCheckedOut_DoesNotDoubleCheckout() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + + var checkInTime = new DateTimeOffset(2026, 9, 24, 9, 0, 0, TimeSpan.Zero); + var checkOutTime = new DateTimeOffset(2026, 9, 24, 18, 0, 0, TimeSpan.Zero); + state.Attendance.Add(new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = emp.Id, + EmployeeName = emp.DisplayName, + OccurredAtUtc = checkOutTime, + EventType = "Уход", + IsAutomatic = false + }); + state.Attendance.Add(new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = emp.Id, + EmployeeName = emp.DisplayName, + OccurredAtUtc = checkInTime, + EventType = "Приход", + IsAutomatic = false + }); + + var nowUtc = new DateTimeOffset(2026, 9, 24, 23, 30, 0, TimeSpan.Zero); + + var checkedOutRecords = await tracker.RunScheduledCheckoutAsync( + state, nowUtc, _ => Task.CompletedTask); + + Assert.Empty(checkedOutRecords); + } +} diff --git a/apps/KronaStaffTerminal.Tests/EmployeeFingerprintTests.cs b/apps/KronaStaffTerminal.Tests/EmployeeFingerprintTests.cs new file mode 100644 index 0000000..e43f9da --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/EmployeeFingerprintTests.cs @@ -0,0 +1,150 @@ +using KronaStaffTerminal.Models; +using KronaStaffTerminal.Tests.Mocks; +using Xunit; + +namespace KronaStaffTerminal.Tests; + +public class EmployeeFingerprintTests +{ + [Fact] + public void Employee_CanHaveMultipleFingerprintsWithDistinctLabels() + { + var emp = new EmployeeRecord + { + Id = Guid.NewGuid().ToString(), + LastName = "Иванов", + FirstName = "Иван", + Patronymic = "Иванович", + Fingerprints = new List() + }; + + var fp1 = new FingerprintRecord + { + Id = 1, + Template = Convert.ToBase64String(new byte[] { 1, 2, 3, 4 }), + Label = "right_index", + EnrolledAtUtc = DateTimeOffset.UtcNow + }; + + var fp2 = new FingerprintRecord + { + Id = 2, + Template = Convert.ToBase64String(new byte[] { 5, 6, 7, 8 }), + Label = "left_thumb", + EnrolledAtUtc = DateTimeOffset.UtcNow + }; + + emp.Fingerprints.Add(fp1); + emp.Fingerprints.Add(fp2); + + Assert.Equal(2, emp.Fingerprints.Count); + Assert.Contains(emp.Fingerprints, f => f.Label == "right_index"); + Assert.Contains(emp.Fingerprints, f => f.Label == "left_thumb"); + } + + [Fact] + public void Employee_CanReplaceSpecificFingerprint() + { + var emp = new EmployeeRecord + { + Id = Guid.NewGuid().ToString(), + DisplayName = "Петров Петр Петрович", + Fingerprints = new List + { + new() { Id = 10, Label = "right_index", Template = "OLD_TEMPLATE" } + } + }; + + // Replace right_index + var existing = emp.Fingerprints.FirstOrDefault(f => f.Label == "right_index"); + Assert.NotNull(existing); + + existing.Template = "NEW_UPDATED_TEMPLATE"; + existing.EnrolledAtUtc = DateTimeOffset.UtcNow; + + Assert.Single(emp.Fingerprints); + Assert.Equal("NEW_UPDATED_TEMPLATE", emp.Fingerprints[0].Template); + } + + [Fact] + public void Employee_CanDeleteSingleFingerprintWithoutAffectingOthers() + { + var emp = new EmployeeRecord + { + Id = Guid.NewGuid().ToString(), + DisplayName = "Сидоров Сидор", + Fingerprints = new List + { + new() { Id = 1, Label = "right_thumb", Template = "T1" }, + new() { Id = 2, Label = "right_index", Template = "T2" } + } + }; + + // Remove right_thumb + emp.Fingerprints.RemoveAll(f => f.Label == "right_thumb"); + + Assert.Single(emp.Fingerprints); + Assert.Equal("right_index", emp.Fingerprints[0].Label); + } + + [Fact] + public void DuplicateFingerprint_ProhibitedAcrossDifferentEmployees() + { + var employees = new List + { + new() + { + Id = "emp-1", + DisplayName = "Алексеев Алексей", + Fingerprints = new List + { + new() { Id = 100, Label = "right_index", Template = "UNIQUE_TEMPLATE_HASH_A" } + } + }, + new() + { + Id = "emp-2", + DisplayName = "Борисов Борис", + Fingerprints = new List() + } + }; + + // Attempting to enroll the same template to emp-2 + var candidateTemplate = "UNIQUE_TEMPLATE_HASH_A"; + var candidateEmployeeId = "emp-2"; + + // Validation logic used by terminal + var conflict = employees.FirstOrDefault(e => + e.Id != candidateEmployeeId && + e.Fingerprints.Any(f => f.Template == candidateTemplate)); + + Assert.NotNull(conflict); + Assert.Equal("emp-1", conflict.Id); + Assert.Equal("Алексеев Алексей", conflict.DisplayName); + } + + [Fact] + public void MockScanner_SimulatesMultiFingerEnrollmentAndMatching() + { + using var scanner = new MockFingerprintScanner(); + var recognizedId = -1; + var recognizedScore = 0; + + scanner.FingerRecognized += (id, score) => + { + recognizedId = id; + recognizedScore = score; + }; + + scanner.Connect(0, new[] { (42, "TEMPLATE_42"), (99, "TEMPLATE_99") }); + Assert.True(scanner.IsConnected); + + scanner.SimulateFingerScan(42, 95); + Assert.Equal(42, recognizedId); + Assert.Equal(95, recognizedScore); + + scanner.SimulateFingerScan(99, 88); + Assert.Equal(99, recognizedId); + Assert.Equal(88, recognizedScore); + } +} diff --git a/apps/KronaStaffTerminal.Tests/KronaStaffTerminal.Tests.csproj b/apps/KronaStaffTerminal.Tests/KronaStaffTerminal.Tests.csproj new file mode 100644 index 0000000..9d60a86 --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/KronaStaffTerminal.Tests.csproj @@ -0,0 +1,44 @@ + + + + net10.0-windows10.0.26100.0 + enable + enable + false + true + x64 + x64 + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + ..\KronaStaffTerminal\Vendor\ZKFinger\x64\libzkfpcsharp.dll + true + + + + + + + + + + + + + + + + + diff --git a/apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs b/apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs new file mode 100644 index 0000000..e89b438 --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs @@ -0,0 +1,144 @@ +using KronaStaffTerminal.Services; + +namespace KronaStaffTerminal.Tests.Mocks; + +public sealed class MockFingerprintScanner : IFingerprintScanner +{ + private readonly List _devices = new(); + private readonly Dictionary _loadedTemplates = new(); + private int _registrationTargetId; + private int _registrationStep; + + public ScannerState State { get; private set; } = ScannerState.Disconnected; + public int DeviceCount => _devices.Count; + public int ReaderIndex { get; private set; } = -1; + public IReadOnlyList DiscoveredDevices => _devices; + public bool IsConnected => State == ScannerState.Connected || State == ScannerState.Busy; + public int SkippedTemplateCount { get; set; } = 0; + public int ConnectCallCount { get; private set; } = 0; + + public event Action? FingerRecognized; + public event Action? RegistrationProgress; + public event Action? RegistrationCompleted; + public event Action? StatusChanged; + public event Action? StateChanged; + public event Action? ConnectionLost; + public event Action? RegistrationCancelled; + + public MockFingerprintScanner() + { + // Default with 1 virtual SLK20R + _devices.Add(new ScannerDeviceInfo(0, "SLK20R-Virtual", "ZK-VIRT-001", "SLK20R [ZK-VIRT-001]")); + } + + public void AddVirtualDevice(int index, string model, string serial) + { + _devices.Add(new ScannerDeviceInfo(index, model, serial, $"{model} [{serial}]")); + } + + public void ClearDevices() + { + _devices.Clear(); + } + + public IReadOnlyList DiscoverDevices() + { + return _devices; + } + + public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates) + { + ConnectCallCount++; + if (_devices.Count == 0) + { + State = ScannerState.DriverError; + StateChanged?.Invoke(ScannerState.DriverError, "No devices found"); + return; + } + + ReaderIndex = readerIndex; + _loadedTemplates.Clear(); + foreach (var (id, tpl) in templates) + { + _loadedTemplates[id] = tpl; + } + + State = ScannerState.Connected; + StateChanged?.Invoke(ScannerState.Connected, "Scanner ready"); + StatusChanged?.Invoke("Scanner ready"); + } + + public void Disconnect() + { + State = ScannerState.Disconnected; + ReaderIndex = -1; + _loadedTemplates.Clear(); + StateChanged?.Invoke(ScannerState.Disconnected, "Scanner disconnected"); + StatusChanged?.Invoke("Scanner disconnected"); + } + + public void BeginRegistration(int fingerprintId) + { + _registrationTargetId = fingerprintId; + _registrationStep = 0; + State = ScannerState.Busy; + StateChanged?.Invoke(ScannerState.Busy, "Enrolling finger"); + } + + public void CancelRegistration() + { + _registrationTargetId = 0; + _registrationStep = 0; + if (State == ScannerState.Busy) + { + State = ScannerState.Connected; + StateChanged?.Invoke(ScannerState.Connected, "Registration cancelled"); + } + RegistrationCancelled?.Invoke(); + } + + public void SimulateFingerScan(int fingerprintId, int score = 85) + { + if (State != ScannerState.Connected) + { + return; + } + + FingerRecognized?.Invoke(fingerprintId, score); + } + + public void SimulateEnrollmentSample(string? finalTemplate = null) + { + if (State != ScannerState.Busy) return; + + _registrationStep++; + RegistrationProgress?.Invoke(_registrationStep); + + if (_registrationStep >= 3) + { + string tpl = finalTemplate ?? Convert.ToBase64String(new byte[512]); + _loadedTemplates[_registrationTargetId] = tpl; + State = ScannerState.Connected; + StateChanged?.Invoke(ScannerState.Connected, "Registration complete"); + RegistrationCompleted?.Invoke(_registrationTargetId, tpl); + } + } + + public void SimulateDisconnect() + { + State = ScannerState.Disconnected; + StateChanged?.Invoke(ScannerState.Disconnected, "Device unplugged"); + ConnectionLost?.Invoke(); + } + + public void SimulateReconnect() + { + State = ScannerState.Connected; + StateChanged?.Invoke(ScannerState.Connected, "Device reconnected"); + } + + public void Dispose() + { + Disconnect(); + } +} diff --git a/apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs b/apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs new file mode 100644 index 0000000..735bb4c --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs @@ -0,0 +1,117 @@ +using KronaStaffTerminal.Models; +using KronaStaffTerminal.Services; +using KronaStaffTerminal.Tests.Mocks; +using Xunit; + +namespace KronaStaffTerminal.Tests; + +public class ScannerRecoveryTests +{ + [Fact] + public void Scanner_SimulatesUsbDisconnectAndFiresConnectionLost() + { + using var scanner = new MockFingerprintScanner(); + scanner.Connect(0, Array.Empty<(int, string)>()); + Assert.True(scanner.IsConnected); + Assert.Equal(ScannerState.Connected, scanner.State); + + var connectionLostFired = false; + ScannerState? reportedState = null; + + scanner.ConnectionLost += () => connectionLostFired = true; + scanner.StateChanged += (state, _) => reportedState = state; + + // Simulate physical USB drop + scanner.SimulateDisconnect(); + + Assert.False(scanner.IsConnected); + Assert.Equal(ScannerState.Disconnected, scanner.State); + Assert.True(connectionLostFired); + Assert.Equal(ScannerState.Disconnected, reportedState); + } + + [Fact] + public void Scanner_SimulatesUsbReconnectAndRestoresReadyState() + { + using var scanner = new MockFingerprintScanner(); + scanner.Connect(0, Array.Empty<(int, string)>()); + scanner.SimulateDisconnect(); + Assert.Equal(ScannerState.Disconnected, scanner.State); + + ScannerState? reportedState = null; + scanner.StateChanged += (state, _) => reportedState = state; + + // Simulate USB re-plug + scanner.SimulateReconnect(); + + Assert.True(scanner.IsConnected); + Assert.Equal(ScannerState.Connected, scanner.State); + Assert.Equal(ScannerState.Connected, reportedState); + } + + [Fact] + public void AdminPinVerification_OperatesNormallyWhenScannerIsDisconnected() + { + using var scanner = new MockFingerprintScanner(); + scanner.SimulateDisconnect(); + Assert.Equal(ScannerState.Disconnected, scanner.State); + + // Verify that AdminPinService functions 100% independently of hardware scanner state + var (salt, hash, iterations) = AdminPinService.Hash("5555"); + var settings = new TerminalSettings + { + AdminPinSalt = salt, + AdminPinHash = hash, + AdminPinIterations = iterations + }; + + var now = DateTimeOffset.UtcNow; + var allowed = AdminPinService.VerifyAndRecord( + "5555", settings, now, out var error, out var rehashNeeded); + + Assert.True(allowed); + Assert.Null(error); + Assert.False(rehashNeeded); + } + + [Fact] + public async Task Scanner_AutoReconnectLogic_CallsConnectSuccessfully() + { + using var scanner = new MockFingerprintScanner(); + scanner.Connect(0, Array.Empty<(int, string)>()); + Assert.Equal(1, scanner.ConnectCallCount); + + var reconnectTcs = new TaskCompletionSource(); + scanner.ConnectionLost += async () => + { + // Simulate typical application retry loop + int retries = 0; + while (retries < 5) + { + await Task.Delay(10, TestContext.Current.CancellationToken); + try + { + scanner.Connect(0, Array.Empty<(int, string)>()); + if (scanner.IsConnected) + { + reconnectTcs.TrySetResult(); + return; + } + } + catch { } + retries++; + } + reconnectTcs.TrySetException(new Exception("Failed to reconnect")); + }; + + scanner.SimulateDisconnect(); + // Emulate the hardware coming back after a short delay + await Task.Delay(20, TestContext.Current.CancellationToken); + scanner.AddVirtualDevice(0, "SLK20R", "SERIAL"); // ensure device is discoverable if it was cleared + + await reconnectTcs.Task; + + Assert.True(scanner.ConnectCallCount > 1); + Assert.Equal(ScannerState.Connected, scanner.State); + } +} diff --git a/apps/KronaStaffTerminal/App.xaml.cs b/apps/KronaStaffTerminal/App.xaml.cs index 7a7d45f..523bbcc 100644 --- a/apps/KronaStaffTerminal/App.xaml.cs +++ b/apps/KronaStaffTerminal/App.xaml.cs @@ -86,7 +86,7 @@ public partial class App : Application Window = new MainWindow(); DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); - _trayService = new TrayService(ShowWindow, RequestExit); + _trayService = new TrayService(ShowWindow, RequestExit, ShowWindow, RequestSync); _activationEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivationEventName); _activationCancellation = new CancellationTokenSource(); _activationTask = Task.Run(() => ListenForActivation(_activationCancellation.Token)); @@ -123,6 +123,9 @@ public partial class App : Application public void NotifyMinimizedToTray() => _trayService?.ShowRunningBalloon(); + public void UpdateTray(TrayState state, int pendingCount, string? statusText = null) => + _trayService?.UpdateState(state, pendingCount, statusText); + private static void ShowWindow() { if (Window is MainWindow mainWindow) @@ -131,6 +134,14 @@ public partial class App : Application } } + private static void RequestSync() + { + if (Window is MainWindow mainWindow && mainWindow.Content is FrameworkElement fe && fe.FindName("RootFrame") is Microsoft.UI.Xaml.Controls.Frame frame && frame.Content is MainPage page) + { + _ = page.SyncPendingAttendanceAsync(); + } + } + private static void RequestExit() { if (Window is MainWindow mainWindow) diff --git a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj index 9852ddd..20eddb7 100644 --- a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj +++ b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj @@ -71,6 +71,7 @@ + diff --git a/apps/KronaStaffTerminal/MainPage.xaml.cs b/apps/KronaStaffTerminal/MainPage.xaml.cs index 835b50a..1a4d976 100644 --- a/apps/KronaStaffTerminal/MainPage.xaml.cs +++ b/apps/KronaStaffTerminal/MainPage.xaml.cs @@ -15,8 +15,14 @@ public sealed partial class MainPage : Page private readonly TerminalStore _store = new(); private readonly NotificationService _notifications = new(); private readonly AttendanceSyncService _attendanceSync = new(); - private readonly FingerprintScanner _scanner = new(); + private readonly AttendanceTracker _attendanceTracker = new(); + private readonly TerminalIpcClient _ipcClient = new(); + private readonly IFingerprintScanner _scanner; private readonly IUpdateSource _updateSource = new ForgejoUpdateSource(); + + /// Инъекция фабрики для тестирования. По умолчанию создаёт реальный FingerprintScanner. + public static Func? ScannerFactory { get; set; } + private TerminalState _state = new(); private bool _loaded; private TaskCompletionSource? _adminAuthCompletion; @@ -37,6 +43,7 @@ public sealed partial class MainPage : Page private int? _attendanceSuppressedFingerprintId; private DateTimeOffset _attendanceSuppressedUntilUtc; private string? _registrationEmployeeId; + private string _registrationFingerLabel = "Отпечаток"; private bool _refreshingLists; private bool _applicationLocked = true; private string _lastAdminActor = "unknown"; @@ -54,8 +61,11 @@ public sealed partial class MainPage : Page CrashLogService.Write(exception); throw; } + + _scanner = ScannerFactory?.Invoke() ?? new FingerprintScanner(); _scanner.StatusChanged += message => Enqueue(() => SetStatus(message)); - _scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3")); + _scanner.StateChanged += (state, message) => Enqueue(() => HandleScannerStateChanged(state, message)); + _scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Мастер регистрации: принят скан {count}/3. Уберите палец и приложите снова.")); _scanner.RegistrationCompleted += (id, template) => EnqueueTask(() => CompleteRegistrationAsync(id, template)); _scanner.FingerRecognized += (id, score) => EnqueueTask(() => HandleRecognizedFingerAsync(id, score)); _scanner.ConnectionLost += () => EnqueueTask(ReconnectScannerAsync); @@ -234,25 +244,33 @@ public sealed partial class MainPage : Page } var enteredPin = UnlockPinBox.Password; - var authorized = await Task.Run(() => - AdminPinService.Verify(enteredPin, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations) || - (!string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal) && - AdminPinService.Verify(enteredPin.Trim(), _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations))); + var authorized = AdminPinService.VerifyAndRecord( + enteredPin, + _state.Settings, + DateTimeOffset.UtcNow, + out var errorMessage, + out var rehashNeeded); + + if (!authorized && !string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal)) + { + authorized = AdminPinService.VerifyAndRecord( + enteredPin.Trim(), + _state.Settings, + DateTimeOffset.UtcNow, + out errorMessage, + out rehashNeeded); + } + + await _store.SaveAsync(_state); + UnlockPinBox.Password = ""; + if (authorized) { - _state.Settings.FailedAdminPinAttempts = 0; - _state.Settings.AdminPinBlockedUntilUtc = default; - await _store.SaveAsync(_state); UnlockApplication("local-pin", "Вход выполнен по PIN администратора."); return; } - _state.Settings.FailedAdminPinAttempts++; - var delaySeconds = Math.Min(900, 1 << Math.Min(_state.Settings.FailedAdminPinAttempts - 1, 10)); - _state.Settings.AdminPinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds); - UnlockPinBox.Password = ""; - LockScreenMessage.Text = $"Неверный PIN. Следующая попытка через {delaySeconds} сек."; - await _store.SaveAsync(_state); + LockScreenMessage.Text = errorMessage ?? "Неверный PIN."; } private void LockAdminSession(string message) @@ -344,6 +362,24 @@ public sealed partial class MainPage : Page return true; } + private void HandleScannerStateChanged(ScannerState state, string message) + { + SetStatus(message, state switch + { + ScannerState.Connected => InfoBarSeverity.Success, + ScannerState.Busy => InfoBarSeverity.Informational, + ScannerState.DriverError => InfoBarSeverity.Error, + _ => InfoBarSeverity.Warning + }); + + var pendingCount = _state?.Attendance?.Count(a => !a.ServerSynced) ?? 0; + var trayState = state == ScannerState.Connected + ? (_state?.Attendance?.Any(a => !a.ServerSynced && a.ServerSyncError != null) == true ? TrayState.NoServer : TrayState.Normal) + : TrayState.NoScanner; + + ((App)Application.Current).UpdateTray(trayState, pendingCount, message); + } + private void ConnectScanner() { try @@ -356,11 +392,12 @@ public sealed partial class MainPage : Page try { ReaderCombo.Items.Clear(); - for (var index = 0; index < _scanner.DeviceCount; index++) + foreach (var device in _scanner.DiscoveredDevices) { - ReaderCombo.Items.Add($"SLK20R №{index + 1}"); + ReaderCombo.Items.Add(device.DisplayName); } - ReaderCombo.SelectedIndex = _scanner.ReaderIndex; + var selectedIdx = _scanner.DiscoveredDevices.ToList().FindIndex(d => d.Index == _scanner.ReaderIndex); + ReaderCombo.SelectedIndex = selectedIdx >= 0 ? selectedIdx : 0; } finally { @@ -371,16 +408,23 @@ public sealed partial class MainPage : Page _state.Settings.ReaderIndex = _scanner.ReaderIndex; SaveStateInBackground(); } + + var activeDevice = _scanner.DiscoveredDevices.FirstOrDefault(d => d.Index == _scanner.ReaderIndex); + var deviceName = activeDevice?.DisplayName ?? $"SLK20R №{_scanner.ReaderIndex + 1}"; + if (_scanner.SkippedTemplateCount > 0) { SetStatus( - $"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Не удалось загрузить отпечатков: {_scanner.SkippedTemplateCount}. Перерегистрируйте их в разделе сотрудников.", + $"Сканер {deviceName} подключён. Не удалось загрузить отпечатков: {_scanner.SkippedTemplateCount}. Перерегистрируйте их в разделе сотрудников.", InfoBarSeverity.Warning); } else { - SetStatus($"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success); + SetStatus($"Сканер {deviceName} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success); } + + var pendingCount = _state.Attendance.Count(a => !a.ServerSynced); + ((App)Application.Current).UpdateTray(TrayState.Normal, pendingCount, $"Сканер {deviceName} подключён"); } catch (Exception exception) { @@ -395,6 +439,8 @@ public sealed partial class MainPage : Page _refreshingReaders = false; } SetStatus($"{exception.Message} Проверьте USB-подключение и нажмите «Найти сканеры».", InfoBarSeverity.Error); + var pendingCount = _state?.Attendance?.Count(a => !a.ServerSynced) ?? 0; + ((App)Application.Current).UpdateTray(TrayState.NoScanner, pendingCount, "Сканер не подключён"); } } @@ -634,6 +680,7 @@ public sealed partial class MainPage : Page DefaultButton = ContentDialogButton.Close }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + if (!await AuthorizeAdminAsync("деактивация сотрудника")) return; employee.IsBlocked = true; employee.BlockedAtUtc = DateTimeOffset.UtcNow; @@ -670,6 +717,54 @@ public sealed partial class MainPage : Page SetStatus("Сканер не подключён.", InfoBarSeverity.Error); return; } + + var fingerCombo = new ComboBox + { + Header = "Выберите палец для регистрации", + ItemsSource = new[] + { + "Указательный (правая рука)", + "Большой (правая рука)", + "Средний (правая рука)", + "Безымянный (правая рука)", + "Мизинец (правая рука)", + "Указательный (левая рука)", + "Большой (левая рука)", + "Средний (левая рука)", + "Безымянный (левая рука)", + "Мизинец (левая рука)", + "Дополнительный отпечаток" + }, + SelectedIndex = 0, + MinWidth = 280 + }; + + var wizardDialog = new ContentDialog + { + XamlRoot = XamlRoot, + Title = $"Мастер регистрации отпечатка: {employee.DisplayName}", + Content = new StackPanel + { + Spacing = 12, + Children = + { + new TextBlock + { + Text = "Шаг 1 из 3: Выберите палец для сканирования.\nПосле нажатия «Начать» приложите палец к сканеру 3 раза.", + TextWrapping = TextWrapping.Wrap + }, + fingerCombo + } + }, + PrimaryButtonText = "Начать регистрацию", + CloseButtonText = "Отмена", + DefaultButton = ContentDialogButton.Primary + }; + + if (await wizardDialog.ShowAsync() != ContentDialogResult.Primary) return; + + _registrationFingerLabel = (string)fingerCombo.SelectedItem; + var fingerprintId = _state.Employees .SelectMany(item => item.Fingerprints) .Select(item => item.Id) @@ -677,6 +772,7 @@ public sealed partial class MainPage : Page .Max() + 1; _registrationEmployeeId = employee.Id; _scanner.BeginRegistration(fingerprintId); + SetStatus($"Мастер регистрации: приложите {_registrationFingerLabel} к сканеру 3 раза.", InfoBarSeverity.Informational); } private void CancelRegistration_Click(object sender, RoutedEventArgs e) @@ -709,17 +805,31 @@ public sealed partial class MainPage : Page { var employee = _state.Employees.FirstOrDefault(item => item.Id == _registrationEmployeeId); if (employee is null) return; - employee.Fingerprints.Add(new FingerprintRecord + + var existingSlot = employee.Fingerprints.FirstOrDefault(f => f.Label == _registrationFingerLabel); + if (existingSlot is not null) { - Id = fingerprintId, - Template = template, - Label = $"Отпечаток {employee.Fingerprints.Count + 1}" - }); + existingSlot.Id = fingerprintId; + existingSlot.Template = template; + existingSlot.EnrolledAtUtc = DateTimeOffset.UtcNow; + } + else + { + employee.Fingerprints.Add(new FingerprintRecord + { + Id = fingerprintId, + Template = template, + Label = _registrationFingerLabel, + EnrolledAtUtc = DateTimeOffset.UtcNow + }); + } + _registrationEmployeeId = null; await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); - TerminalMessage.Text = $"Новый отпечаток сотрудника «{TerminalDisplayName(employee)}» зарегистрирован"; - SetStatus($"Добавлен отпечаток №{employee.Fingerprints.Count} сотрудника «{employee.DisplayName}».", InfoBarSeverity.Success); + ConnectScanner(); + TerminalMessage.Text = $"Отпечаток «{_registrationFingerLabel}» сотрудника «{TerminalDisplayName(employee)}» успешно сохранён"; + SetStatus($"Отпечаток «{_registrationFingerLabel}» сотрудника «{employee.DisplayName}» зарегистрирован.", InfoBarSeverity.Success); } private async void DeleteFingerprints_Click(object sender, RoutedEventArgs e) @@ -742,23 +852,45 @@ public sealed partial class MainPage : Page return; } + var options = new List { "Все зарегистрированные отпечатки" }; + options.AddRange(employee.Fingerprints.Select(f => $"{f.Label} (ID {f.Id})")); + + var combo = new ComboBox + { + Header = "Выберите отпечаток для удаления", + ItemsSource = options, + SelectedIndex = 0, + MinWidth = 280 + }; + var dialog = new ContentDialog { XamlRoot = XamlRoot, - Title = "Удалить все отпечатки сотрудника?", - Content = $"{employee.DisplayName}: будет удалено отпечатков — {employee.Fingerprints.Count}. Сам сотрудник останется.", - PrimaryButtonText = "Удалить отпечатки", + Title = $"Удаление отпечатков: {employee.DisplayName}", + Content = new StackPanel { Spacing = 10, Children = { combo } }, + PrimaryButtonText = "Удалить", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; - if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; - if (!await AuthorizeAdminAsync("удаление отпечатков сотрудника")) return; - employee.Fingerprints.Clear(); + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + if (!await AuthorizeAdminAsync("удаление отпечатка сотрудника")) return; + + if (combo.SelectedIndex == 0) + { + employee.Fingerprints.Clear(); + SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success); + } + else + { + var chosenFingerprint = employee.Fingerprints[combo.SelectedIndex - 1]; + employee.Fingerprints.RemoveAt(combo.SelectedIndex - 1); + SetStatus($"Отпечаток «{chosenFingerprint.Label}» сотрудника «{employee.DisplayName}» удален.", InfoBarSeverity.Success); + } + await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); ConnectScanner(); - SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success); } private async void ToggleAdministrator_Click(object sender, RoutedEventArgs e) @@ -878,50 +1010,41 @@ public sealed partial class MainPage : Page private async Task RecordAttendanceAsync(EmployeeRecord employee, int score) { var now = DateTimeOffset.UtcNow; - var last = _state.Attendance - .Where(item => item.EmployeeId == employee.Id) - .OrderByDescending(item => item.OccurredAtUtc) - .FirstOrDefault(); - var guard = TimeSpan.FromSeconds(Math.Clamp(_state.Settings.DuplicateScanGuardSeconds, 10, 3600)); - if (last is not null && now - last.OccurredAtUtc < guard) + var result = await _attendanceTracker.TryRecordAttendanceAsync( + _state, + employee, + _scanner.ReaderIndex, + now, + state => _store.SaveAsync(state)); + + if (!result.Success) { - var remaining = Math.Max(1, (int)Math.Ceiling((guard - (now - last.OccurredAtUtc)).TotalSeconds)); - TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {remaining} сек."; - SetStatus($"Повторный скан защищён: событие не создано. Последнее событие — «{last.EventType}».", InfoBarSeverity.Informational); + if (result.IsDuplicateSuppressed) + { + TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {result.RemainingSeconds} сек."; + SetStatus(result.Message, InfoBarSeverity.Informational); + } return; } - var maximumShift = TimeSpan.FromHours(Math.Clamp(_state.Settings.MaximumShiftHours, 1, 48)); - var eventType = last is not null && last.EventType == "Приход" && now - last.OccurredAtUtc <= maximumShift - ? "Уход" - : "Приход"; - var record = new AttendanceRecord - { - EmployeeId = employee.Id, - EmployeeName = employee.DisplayName, - OccurredAtUtc = now, - ReaderIndex = _scanner.ReaderIndex, - EventType = eventType - }; - _state.Attendance.Insert(0, record); - if (_state.Attendance.Count > 100_000) - { - _state.Attendance.RemoveRange(100_000, _state.Attendance.Count - 100_000); - } - await _store.SaveAsync(_state); + var record = result.Record!; RefreshLists(); - TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}"; - SetStatus($"{eventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success); + TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{record.EventType} отмечен в {now.ToLocalTime():HH:mm}"; + SetStatus($"{record.EventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success); - var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee, eventType, now.ToLocalTime()); + var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee, record.EventType, now.ToLocalTime()); record.NotificationDelivered = delivery.Delivered; record.NotificationError = delivery.Error; + + // Try IPC enqueue first for background service + _ = _ipcClient.EnqueueAttendanceAsync(record); + await SyncPendingAttendanceAsync(); await _store.SaveAsync(_state); RefreshLists(); if (delivery.Error is not null) { - SetStatus($"{eventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning); + SetStatus($"{record.EventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning); } } @@ -933,63 +1056,43 @@ public sealed partial class MainPage : Page return; } - var nowUtc = DateTimeOffset.UtcNow; - var hour = Math.Clamp(_state.Settings.AutomaticCheckoutHour, 0, 23); - var minute = Math.Clamp(_state.Settings.AutomaticCheckoutMinute, 0, 59); - var latestScheduleUtc = LatestAutomaticCheckoutScheduleUtc(nowUtc, hour, minute); - if (_state.Settings.LastAutomaticCheckoutUtc >= latestScheduleUtc) return; - _automaticCheckoutInProgress = true; try { - var automaticRecords = new List<(AttendanceRecord Record, EmployeeRecord Employee)>(); - foreach (var employee in _state.Employees) - { - var last = _state.Attendance - .Where(item => item.EmployeeId == employee.Id) - .OrderByDescending(item => item.OccurredAtUtc) - .FirstOrDefault(); - if (last is null || last.EventType != "Приход") continue; + var nowUtc = DateTimeOffset.UtcNow; + var created = await _attendanceTracker.RunScheduledCheckoutAsync( + _state, + nowUtc, + state => _store.SaveAsync(state)); - var checkoutUtc = NextAutomaticCheckoutAfter(last.OccurredAtUtc, hour, minute); - if (checkoutUtc > nowUtc) continue; - automaticRecords.Add((new AttendanceRecord - { - EmployeeId = employee.Id, - EmployeeName = employee.DisplayName, - OccurredAtUtc = checkoutUtc, - ReaderIndex = -1, - EventType = "Уход автоматически" - }, employee)); - } + if (created.Count == 0) return; - _state.Attendance.InsertRange(0, automaticRecords.Select(item => item.Record)); - if (_state.Attendance.Count > 100_000) - { - _state.Attendance.RemoveRange(100_000, _state.Attendance.Count - 100_000); - } - _state.Settings.LastAutomaticCheckoutUtc = latestScheduleUtc; AppendSecurityAudit( "attendance.automatic_checkout", "succeeded", - $"scheduled={latestScheduleUtc:O}; records={automaticRecords.Count}"); - await _store.SaveAsync(_state); + $"scheduled={nowUtc:O}; records={created.Count}"); + RefreshLists(); + TerminalMessage.Text = $"Автоматический уход\nЗакрыто смен: {created.Count}"; + SetStatus($"Автоматически закрыто смен: {created.Count}.", InfoBarSeverity.Informational); - if (automaticRecords.Count == 0) return; - TerminalMessage.Text = $"Автоматический уход\nЗакрыто смен: {automaticRecords.Count}"; - SetStatus($"Автоматически закрыто смен: {automaticRecords.Count}.", InfoBarSeverity.Informational); - - await Task.WhenAll(automaticRecords.Select(async item => + foreach (var record in created) { - var delivery = await _notifications.SendAttendanceAsync( - _state.Settings, - item.Employee, - item.Record.EventType, - item.Record.OccurredAtUtc.ToLocalTime()); - item.Record.NotificationDelivered = delivery.Delivered; - item.Record.NotificationError = delivery.Error; - })); + _ = _ipcClient.EnqueueAttendanceAsync(record); + var employee = _state.Employees.FirstOrDefault(e => e.Id == record.EmployeeId); + if (employee is not null) + { + var delivery = await _notifications.SendAttendanceAsync( + _state.Settings, + employee, + record.EventType, + record.OccurredAtUtc.ToLocalTime()); + record.NotificationDelivered = delivery.Delivered; + record.NotificationError = delivery.Error; + } + } + + await SyncPendingAttendanceAsync(); await _store.SaveAsync(_state); RefreshLists(); } @@ -1186,58 +1289,48 @@ public sealed partial class MainPage : Page // PasswordBox is a WinUI object and must only be accessed from the UI // thread. Capture its value before performing PBKDF2 verification. var enteredPin = pinBox.Password; - string verifiedPin = enteredPin; + string? pinErrorMessage = null; + var securityStateChanged = false; + if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor) { - pinAuthorized = await Task.Run(() => - AdminPinService.Verify( - enteredPin, - _state.Settings.AdminPinSalt, - _state.Settings.AdminPinHash, - _state.Settings.AdminPinIterations) || - (!string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal) && - AdminPinService.Verify( - enteredPin.Trim(), - _state.Settings.AdminPinSalt, - _state.Settings.AdminPinHash, - _state.Settings.AdminPinIterations))); - } - var authorized = fingerprintAuthorized || pinAuthorized; - var securityStateChanged = false; - if (authorized) - { - _state.Settings.FailedAdminPinAttempts = 0; - _state.Settings.AdminPinBlockedUntilUtc = default; + pinAuthorized = AdminPinService.VerifyAndRecord( + enteredPin, + _state.Settings, + DateTimeOffset.UtcNow, + out pinErrorMessage, + out _); + + if (!pinAuthorized && !string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal)) + { + pinAuthorized = AdminPinService.VerifyAndRecord( + enteredPin.Trim(), + _state.Settings, + DateTimeOffset.UtcNow, + out pinErrorMessage, + out _); + } securityStateChanged = true; if (pinAuthorized) { _lastAdminActor = "local-pin"; - if (_state.Settings.AdminPinIterations < AdminPinService.CurrentIterations) - { - if (!AdminPinService.Verify(verifiedPin, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations)) - { - verifiedPin = verifiedPin.Trim(); - } - var upgraded = await Task.Run(() => AdminPinService.Hash(verifiedPin)); - _state.Settings.AdminPinSalt = upgraded.Salt; - _state.Settings.AdminPinHash = upgraded.Hash; - _state.Settings.AdminPinIterations = upgraded.Iterations; - } } } - else if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor) + else if (fingerprintAuthorized) { - _state.Settings.FailedAdminPinAttempts++; - var delaySeconds = Math.Min(900, 1 << Math.Min(_state.Settings.FailedAdminPinAttempts - 1, 10)); - _state.Settings.AdminPinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds); + _state.Settings.FailedAdminPinAttempts = 0; + _state.Settings.AdminPinBlockedUntilUtc = default; securityStateChanged = true; } + + var authorized = fingerprintAuthorized || pinAuthorized; if (securityStateChanged) await _store.SaveAsync(_state); _adminAuthCompletion = null; _adminAuthDialog = null; + SetStatus(authorized ? fingerprintAuthorized ? "Вход выполнен по отпечатку администратора." : "Вход выполнен по PIN администратора." - : dialogResult == ContentDialogResult.Primary ? "Неверный PIN администратора." : "Вход отменён.", + : dialogResult == ContentDialogResult.Primary ? (pinErrorMessage ?? "Неверный PIN администратора.") : "Вход отменён.", authorized ? InfoBarSeverity.Success : dialogResult == ContentDialogResult.Primary ? InfoBarSeverity.Error : InfoBarSeverity.Warning); return authorized; } @@ -1464,19 +1557,41 @@ public sealed partial class MainPage : Page CurrentVersionText.Text = $"Текущая версия: {ForgejoUpdateSource.CurrentVersion.ToString(3)}"; } - private async Task SyncPendingAttendanceAsync() + public async Task SyncPendingAttendanceAsync() { if (_attendanceSyncInProgress || _stateLoadFailed) return; _attendanceSyncInProgress = true; try { - await _attendanceSync.SyncPendingAsync(_state); - await _store.SaveAsync(_state); + var serviceAvailable = await _ipcClient.IsServiceAvailableAsync(); + if (serviceAvailable) + { + var ipcResult = await _ipcClient.TriggerSyncAsync(); + _state = await _store.LoadAsync(); + } + else + { + await _attendanceSync.SyncPendingAsync(_state); + await _store.SaveAsync(_state); + } + + var pendingCount = _state.Attendance.Count(a => !a.ServerSynced); + var trayState = !_scanner.IsConnected + ? TrayState.NoScanner + : (_state.Attendance.Any(a => !a.ServerSynced && a.ServerSyncError != null) + ? TrayState.NoServer + : TrayState.Normal); + + ((App)Application.Current).UpdateTray(trayState, pendingCount); } catch (InvalidOperationException exception) { SetStatus(exception.Message, InfoBarSeverity.Warning); } + catch (Exception ex) + { + CrashLogService.Write(ex); + } finally { _attendanceSyncInProgress = false; diff --git a/apps/KronaStaffTerminal/Models/TerminalModels.cs b/apps/KronaStaffTerminal/Models/TerminalModels.cs index 368ca82..8dfabd0 100644 --- a/apps/KronaStaffTerminal/Models/TerminalModels.cs +++ b/apps/KronaStaffTerminal/Models/TerminalModels.cs @@ -6,7 +6,21 @@ public sealed class EmployeeRecord { public string Id { get; set; } = UlidGenerator.New(); public string PersonnelNumber { get; set; } = ""; - public string DisplayName { get; set; } = ""; + public string LastName { get; set; } = ""; + public string FirstName { get; set; } = ""; + public string Patronymic { get; set; } = ""; + private string _displayName = ""; + public string DisplayName + { + get + { + if (!string.IsNullOrWhiteSpace(_displayName)) return _displayName; + var parts = new[] { LastName, FirstName, Patronymic }.Where(p => !string.IsNullOrWhiteSpace(p)); + var combined = string.Join(" ", parts); + return string.IsNullOrWhiteSpace(combined) ? "" : combined; + } + set => _displayName = value; + } public string Position { get; set; } = ""; public string Gender { get; set; } = ""; public bool IsAdministrator { get; set; } @@ -17,6 +31,15 @@ public sealed class EmployeeRecord public string VkPeerId { get; set; } = ""; public string MaxUserId { get; set; } = ""; + public void SetFullName(string lastName, string firstName, string patronymic) + { + LastName = lastName?.Trim() ?? ""; + FirstName = firstName?.Trim() ?? ""; + Patronymic = patronymic?.Trim() ?? ""; + var parts = new[] { LastName, FirstName, Patronymic }.Where(p => !string.IsNullOrWhiteSpace(p)); + _displayName = string.Join(" ", parts); + } + // Legacy single-fingerprint fields are kept for one-way migration of existing encrypted state. public int FingerprintId { get; set; } public string? FingerprintTemplate { get; set; } @@ -59,6 +82,7 @@ public sealed class FingerprintRecord public int Id { get; set; } public string Template { get; set; } = ""; public string Label { get; set; } = ""; + public DateTimeOffset EnrolledAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class AttendanceRecord @@ -69,6 +93,7 @@ public sealed class AttendanceRecord public DateTimeOffset OccurredAtUtc { get; set; } public int ReaderIndex { get; set; } public string EventType { get; set; } = "Приход"; + public bool IsAutomatic { get; set; } public bool NotificationDelivered { get; set; } public string? NotificationError { get; set; } public bool ServerSynced { get; set; } diff --git a/apps/KronaStaffTerminal/Services/AdminPinService.cs b/apps/KronaStaffTerminal/Services/AdminPinService.cs index bc55a16..a93295f 100644 --- a/apps/KronaStaffTerminal/Services/AdminPinService.cs +++ b/apps/KronaStaffTerminal/Services/AdminPinService.cs @@ -1,16 +1,32 @@ using System.Security.Cryptography; +using System.Text; +using Konscious.Security.Cryptography; +using KronaStaffTerminal.Models; namespace KronaStaffTerminal.Services; public static class AdminPinService { - public const int CurrentIterations = 600_000; + public const string Argon2Prefix = "$argon2id$"; + public const int DefaultArgon2Iterations = 3; + public const int DefaultMemorySizeKb = 65536; // 64 MB + public const int DefaultParallelism = 2; + public const int HashLength = 32; + + public const int LegacyPbkdf2Iterations = 600_000; public static (string Salt, string Hash, int Iterations) Hash(string pin) { var salt = RandomNumberGenerator.GetBytes(16); - var hash = Rfc2898DeriveBytes.Pbkdf2(pin, salt, CurrentIterations, HashAlgorithmName.SHA256, 32); - return (Convert.ToBase64String(salt), Convert.ToBase64String(hash), CurrentIterations); + using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pin)) + { + Salt = salt, + DegreeOfParallelism = DefaultParallelism, + Iterations = DefaultArgon2Iterations, + MemorySize = DefaultMemorySizeKb + }; + var hash = argon2.GetBytes(HashLength); + return (Convert.ToBase64String(salt), Argon2Prefix + Convert.ToBase64String(hash), DefaultArgon2Iterations); } public static bool Verify(string pin, string saltBase64, string hashBase64, int iterations) @@ -23,18 +39,111 @@ public static class AdminPinService try { var salt = Convert.FromBase64String(saltBase64); - var expected = Convert.FromBase64String(hashBase64); - var actual = Rfc2898DeriveBytes.Pbkdf2( + + if (hashBase64.StartsWith(Argon2Prefix, StringComparison.Ordinal)) + { + var rawHash = hashBase64[Argon2Prefix.Length..]; + var expected = Convert.FromBase64String(rawHash); + using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pin)) + { + Salt = salt, + DegreeOfParallelism = DefaultParallelism, + Iterations = Math.Max(1, iterations), + MemorySize = DefaultMemorySizeKb + }; + var actual = argon2.GetBytes(expected.Length); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + + // Backwards compatibility with legacy PBKDF2 hashes + var legacyExpected = Convert.FromBase64String(hashBase64); + var legacyActual = Rfc2898DeriveBytes.Pbkdf2( pin, salt, - Math.Clamp(iterations, 120_000, CurrentIterations), + Math.Clamp(iterations, 120_000, LegacyPbkdf2Iterations), HashAlgorithmName.SHA256, - expected.Length); - return CryptographicOperations.FixedTimeEquals(actual, expected); + legacyExpected.Length); + return CryptographicOperations.FixedTimeEquals(legacyActual, legacyExpected); } catch (FormatException) { return false; } } + + public static TimeSpan CalculateLockout(int failedAttempts) + { + if (failedAttempts < 3) return TimeSpan.Zero; + if (failedAttempts < 5) return TimeSpan.FromSeconds(30 * (failedAttempts - 2)); // 30s, 60s + if (failedAttempts < 8) return TimeSpan.FromMinutes(5); // 5 minutes + return TimeSpan.FromMinutes(15); // 15 minutes max + } + + public static (bool Allowed, string? ErrorMessage) CheckLockout(TerminalSettings settings, DateTimeOffset nowUtc) + { + if (settings.AdminPinBlockedUntilUtc > nowUtc) + { + var remaining = settings.AdminPinBlockedUntilUtc - nowUtc; + var seconds = Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds)); + return (false, $"Ввод PIN временно заблокирован из-за неверных попыток. Попробуйте через {seconds} сек."); + } + return (true, null); + } + + public static bool VerifyAndRecord( + string pin, + TerminalSettings settings, + DateTimeOffset nowUtc, + out string? errorMessage, + out bool rehashNeeded) + { + var (allowed, lockoutMessage) = CheckLockout(settings, nowUtc); + if (!allowed) + { + errorMessage = lockoutMessage; + rehashNeeded = false; + return false; + } + + var isMatch = Verify(pin, settings.AdminPinSalt, settings.AdminPinHash, settings.AdminPinIterations); + if (isMatch) + { + settings.FailedAdminPinAttempts = 0; + settings.AdminPinBlockedUntilUtc = DateTimeOffset.MinValue; + errorMessage = null; + + // Automatically upgrade legacy PBKDF2 hash to Argon2id + if (!settings.AdminPinHash.StartsWith(Argon2Prefix, StringComparison.Ordinal)) + { + var (newSalt, newHash, newIterations) = Hash(pin); + settings.AdminPinSalt = newSalt; + settings.AdminPinHash = newHash; + settings.AdminPinIterations = newIterations; + rehashNeeded = true; + } + else + { + rehashNeeded = false; + } + + return true; + } + + settings.FailedAdminPinAttempts++; + var lockout = CalculateLockout(settings.FailedAdminPinAttempts); + if (lockout > TimeSpan.Zero) + { + settings.AdminPinBlockedUntilUtc = nowUtc.Add(lockout); + var seconds = (int)lockout.TotalSeconds; + errorMessage = $"Неверный PIN. Ввод заблокирован на {seconds} сек."; + } + else + { + var remainingTries = 3 - settings.FailedAdminPinAttempts; + errorMessage = $"Неверный PIN. Осталось попыток до блокировки: {remainingTries}."; + } + + rehashNeeded = false; + return false; + } } diff --git a/apps/KronaStaffTerminal/Services/AttendanceSyncService.cs b/apps/KronaStaffTerminal/Services/AttendanceSyncService.cs index cfc10fd..4b000bb 100644 --- a/apps/KronaStaffTerminal/Services/AttendanceSyncService.cs +++ b/apps/KronaStaffTerminal/Services/AttendanceSyncService.cs @@ -1,90 +1,142 @@ -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; + private readonly HttpClient? _httpClient; - public async Task SyncPendingAsync(TerminalState state, CancellationToken cancellationToken = default) + public AttendanceSyncService(HttpClient? httpClient = null) { + _httpClient = httpClient; + } + + public async Task 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) }; + 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!; - 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); } diff --git a/apps/KronaStaffTerminal/Services/AttendanceTracker.cs b/apps/KronaStaffTerminal/Services/AttendanceTracker.cs new file mode 100644 index 0000000..1217ad9 --- /dev/null +++ b/apps/KronaStaffTerminal/Services/AttendanceTracker.cs @@ -0,0 +1,174 @@ +using KronaStaffTerminal.Models; + +namespace KronaStaffTerminal.Services; + +public sealed record AttendanceRecordResult( + bool Success, + AttendanceRecord? Record, + string Message, + bool IsDuplicateSuppressed, + int RemainingSeconds = 0); + +public sealed class AttendanceTracker +{ + private readonly SemaphoreSlim _gate = new(1, 1); + + public async Task TryRecordAttendanceAsync( + TerminalState state, + EmployeeRecord employee, + int readerIndex, + DateTimeOffset nowUtc, + Func saveStateCallback) + { + await _gate.WaitAsync(); + try + { + var last = state.Attendance + .Where(item => item.EmployeeId == employee.Id) + .OrderByDescending(item => item.OccurredAtUtc) + .FirstOrDefault(); + + var guardSeconds = Math.Clamp(state.Settings.DuplicateScanGuardSeconds, 10, 3600); + var guard = TimeSpan.FromSeconds(guardSeconds); + + if (last is not null && nowUtc - last.OccurredAtUtc < guard) + { + var remaining = Math.Max(1, (int)Math.Ceiling((guard - (nowUtc - last.OccurredAtUtc)).TotalSeconds)); + return new AttendanceRecordResult( + Success: false, + Record: null, + Message: $"Повторный скан отклонён. Повторите через {remaining} сек.", + IsDuplicateSuppressed: true, + RemainingSeconds: remaining); + } + + var maximumShift = TimeSpan.FromHours(Math.Clamp(state.Settings.MaximumShiftHours, 1, 48)); + var isCheckIn = last is null || last.EventType != "Приход" || (nowUtc - last.OccurredAtUtc > maximumShift); + var eventType = isCheckIn ? "Приход" : "Уход"; + + var record = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = employee.Id, + EmployeeName = employee.DisplayName, + OccurredAtUtc = nowUtc, + ReaderIndex = readerIndex, + EventType = eventType, + IsAutomatic = false + }; + + state.Attendance.Insert(0, record); + if (state.Attendance.Count > 100_000) + { + state.Attendance.RemoveRange(100_000, state.Attendance.Count - 100_000); + } + + await saveStateCallback(state); + + return new AttendanceRecordResult( + Success: true, + Record: record, + Message: $"{eventType} отмечен в {nowUtc.ToLocalTime():HH:mm}", + IsDuplicateSuppressed: false); + } + finally + { + _gate.Release(); + } + } + + public async Task> RunScheduledCheckoutAsync( + TerminalState state, + DateTimeOffset nowUtc, + Func saveStateCallback) + { + await _gate.WaitAsync(); + try + { + if (!state.Settings.AutomaticCheckoutEnabled) + { + return []; + } + + var hour = Math.Clamp(state.Settings.AutomaticCheckoutHour, 0, 23); + var minute = Math.Clamp(state.Settings.AutomaticCheckoutMinute, 0, 59); + var latestScheduleUtc = LatestAutomaticCheckoutScheduleUtc(nowUtc, hour, minute); + + if (state.Settings.LastAutomaticCheckoutUtc >= latestScheduleUtc) + { + return []; + } + + var createdRecords = new List(); + foreach (var employee in state.Employees) + { + if (employee.IsBlocked) continue; + + var last = state.Attendance + .Where(item => item.EmployeeId == employee.Id) + .OrderByDescending(item => item.OccurredAtUtc) + .FirstOrDefault(); + + if (last is null || last.EventType != "Приход") continue; + + var checkoutUtc = NextAutomaticCheckoutAfter(last.OccurredAtUtc, hour, minute); + if (checkoutUtc > nowUtc) continue; + + var record = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = employee.Id, + EmployeeName = employee.DisplayName, + OccurredAtUtc = checkoutUtc, + ReaderIndex = -1, + EventType = "Уход автоматически", + IsAutomatic = true + }; + + createdRecords.Add(record); + } + + if (createdRecords.Count > 0) + { + state.Attendance.InsertRange(0, createdRecords); + if (state.Attendance.Count > 100_000) + { + state.Attendance.RemoveRange(100_000, state.Attendance.Count - 100_000); + } + } + + state.Settings.LastAutomaticCheckoutUtc = latestScheduleUtc; + await saveStateCallback(state); + + return createdRecords; + } + finally + { + _gate.Release(); + } + } + + public static DateTimeOffset LatestAutomaticCheckoutScheduleUtc(DateTimeOffset nowUtc, int hour, int minute) + { + var localNow = nowUtc.ToLocalTime(); + var scheduledToday = new DateTimeOffset( + localNow.Year, localNow.Month, localNow.Day, + hour, minute, 0, + localNow.Offset); + + var targetLocal = localNow >= scheduledToday ? scheduledToday : scheduledToday.AddDays(-1); + return targetLocal.ToUniversalTime(); + } + + public static DateTimeOffset NextAutomaticCheckoutAfter(DateTimeOffset startUtc, int hour, int minute) + { + var localStart = startUtc.ToLocalTime(); + var scheduledSameDay = new DateTimeOffset( + localStart.Year, localStart.Month, localStart.Day, + hour, minute, 0, + localStart.Offset); + + var targetLocal = scheduledSameDay > localStart ? scheduledSameDay : scheduledSameDay.AddDays(1); + return targetLocal.ToUniversalTime(); + } +} diff --git a/apps/KronaStaffTerminal/Services/CrashLogService.cs b/apps/KronaStaffTerminal/Services/CrashLogService.cs index b2bc64d..a38996e 100644 --- a/apps/KronaStaffTerminal/Services/CrashLogService.cs +++ b/apps/KronaStaffTerminal/Services/CrashLogService.cs @@ -5,7 +5,7 @@ public static class CrashLogService private const long MaximumLogSize = 1_048_576; private static readonly object Gate = new(); private static readonly string LogDirectory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Krona", "StaffTerminal", "logs"); @@ -29,6 +29,25 @@ public static class CrashLogService } } + public static void WriteDiagnostic(string message) + { + try + { + lock (Gate) + { + Directory.CreateDirectory(LogDirectory); + var path = Path.Combine(LogDirectory, "diagnostic.log"); + RotateIfNeeded(path); + var entry = $"[{DateTimeOffset.Now:O}] {message}{Environment.NewLine}"; + File.AppendAllText(path, entry); + } + } + catch + { + // Diagnostics must never throw + } + } + private static void RotateIfNeeded(string path) { if (!File.Exists(path) || new FileInfo(path).Length < MaximumLogSize) return; diff --git a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs index 532fe88..d2015bf 100644 --- a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs +++ b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs @@ -1,8 +1,47 @@ +using System.Text; using libzkfpcsharp; namespace KronaStaffTerminal.Services; -public sealed class FingerprintScanner : IDisposable +public enum ScannerState +{ + Disconnected, + Connected, + Busy, + DriverError +} + +public sealed record ScannerDeviceInfo( + int Index, + string Model, + string SerialNumber, + string DisplayName); + +public interface IFingerprintScanner : IDisposable +{ + ScannerState State { get; } + int DeviceCount { get; } + int ReaderIndex { get; } + IReadOnlyList DiscoveredDevices { get; } + bool IsConnected { get; } + int SkippedTemplateCount { get; } + + event Action? FingerRecognized; + event Action? RegistrationProgress; + event Action? RegistrationCompleted; + event Action? StatusChanged; + event Action? StateChanged; + event Action? ConnectionLost; + event Action? RegistrationCancelled; + + IReadOnlyList DiscoverDevices(); + void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates); + void Disconnect(); + void BeginRegistration(int fingerprintId); + void CancelRegistration(); +} + +public sealed class FingerprintScanner : IFingerprintScanner { private const int TemplateSize = 2048; private readonly object _gate = new(); @@ -17,63 +56,198 @@ public sealed class FingerprintScanner : IDisposable private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue; private bool _fingerOnSensor; private int _cleanupScheduled; + private List _discoveredDevices = []; + private List<(int FingerprintId, string Template)> _lastLoadedTemplates = []; + private CancellationTokenSource? _reconnectCancellation; + private Task? _reconnectTask; + private bool _autoReconnectEnabled = true; public event Action? FingerRecognized; public event Action? RegistrationProgress; + event Action? IFingerprintScanner.RegistrationCompleted + { + add => RegistrationCompleted += value; + remove => RegistrationCompleted -= value; + } public event Action? RegistrationCompleted; public event Action? StatusChanged; + public event Action? StateChanged; public event Action? ConnectionLost; public event Action? RegistrationCancelled; + public ScannerState State { get; private set; } = ScannerState.Disconnected; public int DeviceCount { get; private set; } public int ReaderIndex { get; private set; } public int SkippedTemplateCount { get; private set; } - public bool IsConnected => _device != IntPtr.Zero; + public IReadOnlyList DiscoveredDevices => _discoveredDevices; + public bool IsConnected => _device != IntPtr.Zero && State == ScannerState.Connected; + + public IReadOnlyList DiscoverDevices() + { + lock (_gate) + { + var devices = new List(); + var seenSerials = new HashSet(StringComparer.OrdinalIgnoreCase); + + var initResult = zkfp2.Init(); + if (initResult != 0 && initResult != 1) + { + CrashLogService.WriteDiagnostic($"zkfp2.Init returned {initResult} during discovery."); + _discoveredDevices = devices; + DeviceCount = 0; + return devices; + } + + try + { + var rawCount = Math.Max(0, zkfp2.GetDeviceCount()); + for (var i = 0; i < rawCount; i++) + { + // If this device is currently open by us, use it or skip opening + IntPtr handle; + var shouldClose = false; + if (_device != IntPtr.Zero && ReaderIndex == i) + { + handle = _device; + } + else + { + handle = zkfp2.OpenDevice(i); + shouldClose = true; + } + + if (handle == IntPtr.Zero) + { + continue; + } + + try + { + var serial = QueryDeviceStringParameter(handle, 1101); + if (string.IsNullOrWhiteSpace(serial)) + { + serial = QueryDeviceStringParameter(handle, 1102); + } + if (string.IsNullOrWhiteSpace(serial)) + { + serial = QueryDeviceStringParameter(handle, 106); + } + + var model = QueryDeviceStringParameter(handle, 1015); + if (string.IsNullOrWhiteSpace(model)) + { + model = "ZKTeco SLK20R"; + } + + // Eliminate duplicate devices: + // ZKFinger SDK 5.3 often exposes 2 device slots for 1 physical USB scanner. + // Filter out ghost handles by checking serial number deduplication. + if (!string.IsNullOrWhiteSpace(serial)) + { + if (seenSerials.Contains(serial)) + { + CrashLogService.WriteDiagnostic($"Device index {i} skipped as duplicate serial: {serial}"); + continue; + } + seenSerials.Add(serial); + } + else if (devices.Count > 0 && rawCount > 1) + { + // If no serial number could be queried but a previous reader was already found + // on SLK20R driver, skip the secondary phantom slot. + CrashLogService.WriteDiagnostic($"Device index {i} skipped as phantom secondary reader slot."); + continue; + } + + var displayName = !string.IsNullOrWhiteSpace(serial) + ? $"{model} ({serial})" + : $"{model} (сканер №{devices.Count + 1})"; + + devices.Add(new ScannerDeviceInfo(i, model, serial, displayName)); + } + finally + { + if (shouldClose && handle != IntPtr.Zero) + { + zkfp2.CloseDevice(handle); + } + } + } + } + finally + { + if (_device == IntPtr.Zero) + { + zkfp2.Terminate(); + } + } + + _discoveredDevices = devices; + DeviceCount = devices.Count; + CrashLogService.WriteDiagnostic($"Device discovery completed: {devices.Count} unique device(s) found."); + return devices; + } + } public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates) + { + _lastLoadedTemplates = templates.ToList(); + _autoReconnectEnabled = true; + StopReconnectLoop(); + + ConnectInternal(readerIndex, _lastLoadedTemplates); + } + + private void ConnectInternal(int readerIndex, IReadOnlyList<(int FingerprintId, string Template)> templates) { Disconnect(); SkippedTemplateCount = 0; + var result = zkfp2.Init(); - if (result != 0) + if (result != 0 && result != 1) { - throw new InvalidOperationException($"SDK ZKTeco не инициализировано (код {result})."); + SetState(ScannerState.DriverError, $"Драйвер сканера вернул ошибку инициализации (код {result})."); + CrashLogService.WriteDiagnostic($"Scanner initialization failed: code {result}"); + StartReconnectLoop(); + throw new InvalidOperationException($"SDK ZKTeco не инициализировано (код {result}). Проверьте драйвер."); } - // GetDeviceCount returns the number of physical readers for the SLK20R - // driver shipped with ZKFinger SDK 5.3. Do not subtract a service slot: - // on a workstation with one reader that would hide the only device. - DeviceCount = Math.Max(0, zkfp2.GetDeviceCount()); - if (DeviceCount <= 0) + var available = DiscoverDevices(); + if (available.Count == 0) { zkfp2.Terminate(); - throw new InvalidOperationException("Подключённые сканеры не найдены."); + SetState(ScannerState.Disconnected, "Подключённые сканеры не найдены."); + CrashLogService.WriteDiagnostic("Scanner connect failed: no devices found."); + StartReconnectLoop(); + throw new InvalidOperationException("Подключённые сканеры не найдены. Подключите USB-сканер."); } - var preferredIndex = Math.Clamp(readerIndex, 0, DeviceCount - 1); - var candidateIndexes = new[] { preferredIndex } - .Concat(Enumerable.Range(0, DeviceCount).Where(index => index != preferredIndex)); - foreach (var candidateIndex in candidateIndexes) - { - var candidateDevice = zkfp2.OpenDevice(candidateIndex); - if (candidateDevice == IntPtr.Zero) continue; - ReaderIndex = candidateIndex; - _device = candidateDevice; - break; - } - if (_device == IntPtr.Zero) + var chosenDevice = available.FirstOrDefault(d => d.Index == readerIndex) ?? available[0]; + var deviceIndex = chosenDevice.Index; + + var candidate = zkfp2.OpenDevice(deviceIndex); + if (candidate == IntPtr.Zero) { zkfp2.Terminate(); - throw new InvalidOperationException("Ни один обнаруженный сканер не удалось открыть. Переподключите USB-кабель."); + SetState(ScannerState.DriverError, "Не удалось открыть сканер. Возможно, устройство занято."); + CrashLogService.WriteDiagnostic($"Failed to open scanner at index {deviceIndex}."); + StartReconnectLoop(); + throw new InvalidOperationException("Не удалось открыть сканер. Переподключите USB-кабель."); } + ReaderIndex = deviceIndex; + _device = candidate; + _database = zkfp2.DBInit(); if (_database == IntPtr.Zero) { zkfp2.CloseDevice(_device); _device = IntPtr.Zero; zkfp2.Terminate(); - throw new InvalidOperationException("Не удалось создать базу распознавания."); + SetState(ScannerState.DriverError, "Не удалось инициализировать базу биометрических шаблонов."); + CrashLogService.WriteDiagnostic("zkfp2.DBInit failed."); + StartReconnectLoop(); + throw new InvalidOperationException("Не удалось создать базу биометрических шаблонов."); } foreach (var item in templates) @@ -96,7 +270,13 @@ public sealed class FingerprintScanner : IDisposable var device = _device; var database = _database; _captureTask = Task.Run(() => CaptureLoop(device, database, _captureCancellation.Token)); - StatusChanged?.Invoke($"Сканер №{ReaderIndex + 1} подключён. Приложите палец."); + + var modelInfo = !string.IsNullOrWhiteSpace(chosenDevice.SerialNumber) + ? $"{chosenDevice.Model} [SN: {chosenDevice.SerialNumber}]" + : chosenDevice.Model; + + SetState(ScannerState.Connected, $"Сканер {modelInfo} подключён. Приложите палец."); + CrashLogService.WriteDiagnostic($"Scanner connected: {modelInfo} at index {deviceIndex}. Loaded {templates.Count - SkippedTemplateCount} templates."); } public void BeginRegistration(int fingerprintId) @@ -108,7 +288,7 @@ public sealed class FingerprintScanner : IDisposable _registrationExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(1); } - StatusChanged?.Invoke("Регистрация: приложите один и тот же палец 3 раза, каждый раз убирая его со стекла."); + SetState(ScannerState.Busy, "Регистрация: приложите один и тот же палец 3 раза, каждый раз убирая его со стекла."); } public void CancelRegistration() @@ -119,6 +299,9 @@ public sealed class FingerprintScanner : IDisposable _registrationCount = 0; _registrationExpiresAtUtc = default; } + + SetState(IsConnected ? ScannerState.Connected : ScannerState.Disconnected, "Регистрация отпечатка отменена."); + RegistrationCancelled?.Invoke(); } private void CaptureLoop(IntPtr device, IntPtr database, CancellationToken cancellationToken) @@ -146,15 +329,15 @@ public sealed class FingerprintScanner : IDisposable var healthSize = 4; if (zkfp2.GetParameters(device, 1, parameter, ref healthSize) != 0) { - StatusChanged?.Invoke("Связь со сканером потеряна. Выполняется переподключение…"); - ConnectionLost?.Invoke(); + HandleConnectionLoss("Потеряна связь со сканером (сбой проверки состояния)."); return; } nextHealthCheck = DateTimeOffset.UtcNow.AddSeconds(5); } var capturedLength = TemplateSize; - if (zkfp2.AcquireFingerprint(device, image, captured, ref capturedLength) == 0) + var acquireResult = zkfp2.AcquireFingerprint(device, image, captured, ref capturedLength); + if (acquireResult == 0) { var now = DateTimeOffset.UtcNow; if (!_fingerOnSensor && now - _lastProcessedCapture > TimeSpan.FromMilliseconds(500)) @@ -164,6 +347,12 @@ public sealed class FingerprintScanner : IDisposable ProcessCapture(database, captured, capturedLength); } } + else if (acquireResult is -6 or -7 or -8 or -10 or -17) + { + // Negative critical device error codes indicate USB disconnect or driver fault + HandleConnectionLoss($"Ошибка сканера (код {acquireResult})."); + return; + } else { _fingerOnSensor = false; @@ -176,8 +365,70 @@ public sealed class FingerprintScanner : IDisposable CrashLogService.Write(exception); if (!cancellationToken.IsCancellationRequested) { - StatusChanged?.Invoke("Ошибка сканера. Выполняется переподключение…"); - ConnectionLost?.Invoke(); + HandleConnectionLoss($"Сбой потока чтения сканера: {exception.Message}"); + } + } + } + + private void HandleConnectionLoss(string reason) + { + CrashLogService.WriteDiagnostic($"Scanner connection lost: {reason}"); + SetState(ScannerState.Disconnected, reason); + ConnectionLost?.Invoke(); + + if (_autoReconnectEnabled) + { + StartReconnectLoop(); + } + } + + private void StartReconnectLoop() + { + lock (_gate) + { + if (_reconnectTask is not null && !_reconnectTask.IsCompleted) return; + _reconnectCancellation = new CancellationTokenSource(); + var token = _reconnectCancellation.Token; + _reconnectTask = Task.Run(() => ReconnectLoopAsync(token)); + } + } + + private void StopReconnectLoop() + { + lock (_gate) + { + _reconnectCancellation?.Cancel(); + _reconnectCancellation?.Dispose(); + _reconnectCancellation = null; + _reconnectTask = null; + } + } + + private async Task ReconnectLoopAsync(CancellationToken token) + { + CrashLogService.WriteDiagnostic("Starting background scanner auto-reconnect loop..."); + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(2500, token); + if (token.IsCancellationRequested) return; + + var devices = DiscoverDevices(); + if (devices.Count > 0) + { + CrashLogService.WriteDiagnostic($"Scanner detected during reconnect: {devices[0].DisplayName}. Connecting..."); + ConnectInternal(ReaderIndex, _lastLoadedTemplates); + return; // Successfully reconnected! + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + CrashLogService.WriteDiagnostic($"Reconnect attempt failed: {ex.Message}"); } } } @@ -190,7 +441,7 @@ public sealed class FingerprintScanner : IDisposable _registrationFingerprintId = null; _registrationCount = 0; _registrationExpiresAtUtc = default; - StatusChanged?.Invoke("Регистрация отпечатка отменена: за 60 секунд не получены три скана."); + SetState(ScannerState.Connected, "Регистрация отпечатка отменена: за 60 секунд не получены три скана."); RegistrationCancelled?.Invoke(); } } @@ -209,7 +460,7 @@ public sealed class FingerprintScanner : IDisposable { _registrationFingerprintId = null; _registrationExpiresAtUtc = default; - StatusChanged?.Invoke("Этот палец уже зарегистрирован. Регистрация отменена."); + SetState(ScannerState.Connected, "Этот палец уже зарегистрирован. Регистрация отменена."); RegistrationCancelled?.Invoke(); return; } @@ -217,7 +468,7 @@ public sealed class FingerprintScanner : IDisposable if (_registrationCount > 0 && zkfp2.DBMatch(database, captured, _registrationSamples[_registrationCount - 1]) <= 0) { - StatusChanged?.Invoke("Это другой палец. Приложите тот же палец ещё раз."); + SetState(ScannerState.Busy, "Это другой палец. Приложите тот же палец ещё раз."); return; } @@ -227,7 +478,7 @@ public sealed class FingerprintScanner : IDisposable RegistrationProgress?.Invoke(_registrationCount); if (_registrationCount < 3) { - StatusChanged?.Invoke($"Скан принято: {_registrationCount}/3. Уберите палец и приложите снова."); + SetState(ScannerState.Busy, $"Скан принят: {_registrationCount}/3. Уберите палец и приложите снова."); return; } @@ -245,12 +496,15 @@ public sealed class FingerprintScanner : IDisposable _registrationExpiresAtUtc = default; if (result == 0) { - RegistrationCompleted?.Invoke(fingerprintId, zkfp2.BlobToBase64(merged, mergedLength)); - StatusChanged?.Invoke("Отпечаток успешно зарегистрирован."); + var templateBase64 = zkfp2.BlobToBase64(merged, mergedLength); + RegistrationCompleted?.Invoke(fingerprintId, templateBase64); + SetState(ScannerState.Connected, "Отпечаток успешно зарегистрирован."); + CrashLogService.WriteDiagnostic($"Fingerprint id {fingerprintId} successfully merged and enrolled."); } else { - StatusChanged?.Invoke($"Не удалось сохранить отпечаток (код {result}). Повторите регистрацию."); + SetState(ScannerState.Connected, $"Не удалось объединить сканы (код {result}). Повторите регистрацию."); + CrashLogService.WriteDiagnostic($"Fingerprint enrollment merge failed: code {result}"); } return; } @@ -263,11 +517,18 @@ public sealed class FingerprintScanner : IDisposable } else { - StatusChanged?.Invoke("Отпечаток не распознан."); + SetState(ScannerState.Connected, "Отпечаток не распознан."); } } } + private void SetState(ScannerState newState, string message) + { + State = newState; + StatusChanged?.Invoke(message); + StateChanged?.Invoke(newState, message); + } + public void Disconnect() { _captureCancellation?.Cancel(); @@ -288,7 +549,7 @@ public sealed class FingerprintScanner : IDisposable TaskScheduler.Default); } throw new TimeoutException( - "Поток чтения сканера не завершился вовремя. Ресурсы будут освобождены сразу после безопасной остановки чтения."); + "Поток чтения сканера не завершился вовремя. Ресурсы будут освобождены после завершения операции."); } } catch (AggregateException exception) @@ -318,11 +579,63 @@ public sealed class FingerprintScanner : IDisposable } zkfp2.Terminate(); Interlocked.Exchange(ref _cleanupScheduled, 0); + if (State != ScannerState.DriverError) + { + State = ScannerState.Disconnected; + } + } + } + + private static string QueryDeviceStringParameter(IntPtr device, int paramCode) + { + var buffer = new byte[256]; + var size = buffer.Length; + if (zkfp2.GetParameters(device, paramCode, buffer, ref size) == 0 && size > 0) + { + var len = 0; + while (len < size && buffer[len] != 0) len++; + if (len > 0) + { + return Encoding.ASCII.GetString(buffer, 0, len).Trim(); + } + } + return ""; + } + + public static bool ProbeSession0Support(out string diagnostic) + { + try + { + var init = zkfp2.Init(); + if (init != 0 && init != 1) + { + diagnostic = $"zkfp2.Init failed with code {init} in current session."; + return false; + } + + var count = zkfp2.GetDeviceCount(); + zkfp2.Terminate(); + + if (count <= 0) + { + diagnostic = "ZKFinger driver reports 0 devices in Session 0 / non-interactive context."; + return false; + } + + diagnostic = $"ZKFinger driver reported {count} device(s)."; + return true; + } + catch (Exception ex) + { + diagnostic = $"Exception probing ZKFinger in Session 0: {ex.Message}"; + return false; } } public void Dispose() { + _autoReconnectEnabled = false; + StopReconnectLoop(); try { Disconnect(); diff --git a/apps/KronaStaffTerminal/Services/LicenseStatus.cs b/apps/KronaStaffTerminal/Services/LicenseStatus.cs new file mode 100644 index 0000000..c0f46fd --- /dev/null +++ b/apps/KronaStaffTerminal/Services/LicenseStatus.cs @@ -0,0 +1,25 @@ +namespace KronaStaffTerminal.Services; + +public enum LicenseStatus +{ + Unknown, + Active, + GracePeriod, + Expired, + Unavailable +} + +public interface ILicenseStatusPort +{ + LicenseStatus GetStatus(); +} + +/// +/// Temporary standalone adapter returning Active for the pilot period. +/// Invariant: Standalone adapters are compatible, migratable, and disableable; +/// they do not become a second permanent source of truth (AGENTS.md #15). +/// +public sealed class StandaloneLicenseAdapter : ILicenseStatusPort +{ + public LicenseStatus GetStatus() => LicenseStatus.Active; +} diff --git a/apps/KronaStaffTerminal/Services/TerminalIpc.cs b/apps/KronaStaffTerminal/Services/TerminalIpc.cs new file mode 100644 index 0000000..11729b2 --- /dev/null +++ b/apps/KronaStaffTerminal/Services/TerminalIpc.cs @@ -0,0 +1,239 @@ +using System.IO.Pipes; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using System.Text.Json; +using KronaStaffTerminal.Models; + +namespace KronaStaffTerminal.Services; + +public sealed class IpcRequest +{ + public string Command { get; set; } = ""; + public string? Payload { get; set; } +} + +public sealed class IpcResponse +{ + public bool Success { get; set; } + public string? ErrorMessage { get; set; } + public IpcServiceStatus? Status { get; set; } + public int? SyncedCount { get; set; } + public string? Payload { get; set; } +} + +public sealed class IpcServiceStatus +{ + public bool ServiceRunning { get; set; } = true; + public string ServiceVersion { get; set; } = "0.2.5"; + public bool ScannerInSession0Supported { get; set; } + public string ScannerDiagnostic { get; set; } = ""; + public int PendingSyncCount { get; set; } + public DateTimeOffset? LastSyncUtc { get; set; } + public string? LastSyncError { get; set; } + public string LicenseStatus { get; set; } = "Active"; +} + +public static class TerminalIpcConstants +{ + public const string PipeName = "KronaStaffTerminalPipe"; + public const string PipeServerName = "."; +} + +public sealed class TerminalIpcClient +{ + public async Task IsServiceAvailableAsync(CancellationToken cancellationToken = default) + { + var response = await SendCommandAsync("Ping", null, cancellationToken); + return response?.Success == true; + } + + public async Task GetStatusAsync(CancellationToken cancellationToken = default) + { + var response = await SendCommandAsync("GetStatus", null, cancellationToken); + return response?.Status; + } + + public async Task EnqueueAttendanceAsync(AttendanceRecord record, CancellationToken cancellationToken = default) + { + var payload = JsonSerializer.Serialize(record); + var response = await SendCommandAsync("EnqueueAttendance", payload, cancellationToken); + return response?.Success == true; + } + + public async Task TriggerSyncAsync(CancellationToken cancellationToken = default) + { + var response = await SendCommandAsync("TriggerSync", null, cancellationToken); + if (response?.Success == true && !string.IsNullOrWhiteSpace(response.Payload)) + { + return JsonSerializer.Deserialize(response.Payload); + } + return null; + } + + public async Task SendCommandAsync(string command, string? payload, CancellationToken cancellationToken = default) + { + try + { + await using var pipe = new NamedPipeClientStream(TerminalIpcConstants.PipeServerName, TerminalIpcConstants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(2)); + + await pipe.ConnectAsync(timeoutCts.Token); + + var request = new IpcRequest { Command = command, Payload = payload }; + var json = JsonSerializer.Serialize(request); + var bytes = Encoding.UTF8.GetBytes(json); + var lengthPrefix = BitConverter.GetBytes(bytes.Length); + await pipe.WriteAsync(lengthPrefix, cancellationToken); + await pipe.WriteAsync(bytes, cancellationToken); + await pipe.FlushAsync(cancellationToken); + + var respLenBytes = new byte[4]; + var read = await pipe.ReadAsync(respLenBytes, cancellationToken); + if (read < 4) return null; + var respLen = BitConverter.ToInt32(respLenBytes); + if (respLen is <= 0 or > 10 * 1024 * 1024) return null; + + var respBytes = new byte[respLen]; + var totalRead = 0; + while (totalRead < respLen) + { + var r = await pipe.ReadAsync(respBytes.AsMemory(totalRead), cancellationToken); + if (r == 0) return null; + totalRead += r; + } + + var responseJson = Encoding.UTF8.GetString(respBytes); + if (string.IsNullOrWhiteSpace(responseJson)) return null; + + return JsonSerializer.Deserialize(responseJson); + } + catch (Exception ex) when (ex is IOException or TimeoutException or OperationCanceledException) + { + // Service not running or unreachable + return null; + } + } +} + +public sealed class TerminalIpcServer : IDisposable +{ + private readonly Func> _handler; + private CancellationTokenSource? _cancellation; + private Task? _listenTask; + private bool _disposed; + + public TerminalIpcServer(Func> handler) + { + _handler = handler; + } + + public void Start() + { + _cancellation = new CancellationTokenSource(); + _listenTask = Task.Run(() => ListenLoopAsync(_cancellation.Token)); + } + + private async Task ListenLoopAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + NamedPipeServerStream? server = null; + try + { + server = CreatePipeServer(); + await server.WaitForConnectionAsync(token); + + // Handle client in background task so server can accept next client + var clientStream = server; + _ = Task.Run(() => HandleClientAsync(clientStream, token), token); + } + catch (OperationCanceledException) + { + server?.Dispose(); + break; + } + catch (Exception ex) + { + CrashLogService.WriteDiagnostic($"IPC server loop error: {ex.Message}"); + server?.Dispose(); + await Task.Delay(500, token); + } + } + } + + private async Task HandleClientAsync(NamedPipeServerStream stream, CancellationToken token) + { + await using (stream) + { + try + { + var reqLenBytes = new byte[4]; + var read = await stream.ReadAsync(reqLenBytes, token); + if (read < 4) return; + var reqLen = BitConverter.ToInt32(reqLenBytes); + if (reqLen is <= 0 or > 10 * 1024 * 1024) return; + + var reqBytes = new byte[reqLen]; + var totalRead = 0; + while (totalRead < reqLen) + { + var r = await stream.ReadAsync(reqBytes.AsMemory(totalRead), token); + if (r == 0) return; + totalRead += r; + } + + var line = Encoding.UTF8.GetString(reqBytes); + if (string.IsNullOrWhiteSpace(line)) return; + + var request = JsonSerializer.Deserialize(line); + var response = request is not null + ? await _handler(request) + : new IpcResponse { Success = false, ErrorMessage = "Invalid request" }; + + var responseJson = JsonSerializer.Serialize(response); + var responseBytes = Encoding.UTF8.GetBytes(responseJson); + var lengthPrefix = BitConverter.GetBytes(responseBytes.Length); + + await stream.WriteAsync(lengthPrefix, token); + await stream.WriteAsync(responseBytes, token); + await stream.FlushAsync(token); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + CrashLogService.WriteDiagnostic($"IPC client handle error: {ex.Message}"); + } + } + } + + private static NamedPipeServerStream CreatePipeServer() + { + var pipeSecurity = new PipeSecurity(); + var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + var adminSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + var usersSid = new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null); + + pipeSecurity.AddAccessRule(new PipeAccessRule(systemSid, PipeAccessRights.FullControl, AccessControlType.Allow)); + pipeSecurity.AddAccessRule(new PipeAccessRule(adminSid, PipeAccessRights.FullControl, AccessControlType.Allow)); + pipeSecurity.AddAccessRule(new PipeAccessRule(usersSid, PipeAccessRights.ReadWrite, AccessControlType.Allow)); + + return NamedPipeServerStreamAcl.Create( + @"global\" + TerminalIpcConstants.PipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous, + inBufferSize: 4096, + outBufferSize: 4096, + pipeSecurity); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _cancellation?.Cancel(); + _cancellation?.Dispose(); + } +} diff --git a/apps/KronaStaffTerminal/Services/TerminalStore.cs b/apps/KronaStaffTerminal/Services/TerminalStore.cs index e18f158..320c2cc 100644 --- a/apps/KronaStaffTerminal/Services/TerminalStore.cs +++ b/apps/KronaStaffTerminal/Services/TerminalStore.cs @@ -1,4 +1,7 @@ +using System.IO; +using System.Security.AccessControl; using System.Security.Cryptography; +using System.Security.Principal; using System.Text.Json; using KronaStaffTerminal.Models; using Microsoft.Win32; @@ -13,18 +16,41 @@ public sealed class TerminalStore private const string InitializedValueName = "StateInitialized"; private static readonly TimeSpan AutomaticRecoveryPointInterval = TimeSpan.FromHours(12); private readonly SemaphoreSlim _saveGate = new(1, 1); - private readonly string _filePath = Path.Combine( + private readonly string _filePath; + + public static string MachineFilePath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Krona", + "StaffTerminal", + "terminal-state.dat"); + + public static string LegacyUserFilePath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Krona", "StaffTerminal", "terminal-state.dat"); + public string FilePath => _filePath; public string? RecoveryNotice { get; private set; } + public TerminalStore(string? filePath = null) + { + _filePath = filePath ?? MachineFilePath; + } + public async Task LoadAsync() { RecoveryNotice = null; - var sourcePath = File.Exists(_filePath) ? _filePath : FindLegacyPackagedState(); + var sourcePath = File.Exists(_filePath) ? _filePath : null; + if (sourcePath is null && File.Exists(LegacyUserFilePath)) + { + sourcePath = LegacyUserFilePath; + } + if (sourcePath is null) + { + sourcePath = FindLegacyPackagedState(); + } + if (sourcePath is null) { var recovered = await TryRecoverFromBackupAsync(); @@ -47,7 +73,7 @@ public sealed class TerminalStore try { var state = await ReadStateAsync(sourcePath); - var migrated = MigrateFingerprints(state); + var migrated = MigrateEmployees(state); if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) { await SaveAsync(state); @@ -108,7 +134,7 @@ public sealed class TerminalStore try { var recovered = await ReadStateAsync(backupPath); - MigrateFingerprints(recovered); + MigrateEmployees(recovered); return recovered; } catch (Exception backupException) when (IsCorruptStateException(backupException)) @@ -125,7 +151,18 @@ public sealed class TerminalStore private static async Task ReadStateAsync(string path) { var encrypted = await File.ReadAllBytesAsync(path); - var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser); + byte[] clear; + try + { + // Primary: LocalMachine (accessible by both Service and Terminal UI on this machine) + clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.LocalMachine); + } + catch (CryptographicException) + { + // Legacy fallback: CurrentUser (if upgrading an older installation) + clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser); + } + var state = JsonSerializer.Deserialize(clear, JsonOptions) ?? throw new InvalidDataException("Файл состояния не содержит данных."); if (state.Employees is null || state.Attendance is null || state.SecurityAudit is null || state.Settings is null) @@ -135,29 +172,38 @@ public sealed class TerminalStore return state; } - private static bool MigrateFingerprints(TerminalState state) + private static bool MigrateEmployees(TerminalState state) { var changed = false; foreach (var employee in state.Employees) { employee.Fingerprints ??= []; - if (employee.FingerprintId <= 0 || string.IsNullOrWhiteSpace(employee.FingerprintTemplate)) + if (employee.FingerprintId > 0 && !string.IsNullOrWhiteSpace(employee.FingerprintTemplate)) { - continue; + if (employee.Fingerprints.All(item => item.Id != employee.FingerprintId)) + { + employee.Fingerprints.Add(new FingerprintRecord + { + Id = employee.FingerprintId, + Template = employee.FingerprintTemplate, + Label = "Отпечаток 1", + EnrolledAtUtc = DateTimeOffset.UtcNow + }); + } + employee.FingerprintId = 0; + employee.FingerprintTemplate = null; + changed = true; } - if (employee.Fingerprints.All(item => item.Id != employee.FingerprintId)) + // Populate FIO if empty and DisplayName is available + if (string.IsNullOrWhiteSpace(employee.LastName) && !string.IsNullOrWhiteSpace(employee.DisplayName)) { - employee.Fingerprints.Add(new FingerprintRecord - { - Id = employee.FingerprintId, - Template = employee.FingerprintTemplate, - Label = "Отпечаток 1" - }); + var parts = employee.DisplayName.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 0) employee.LastName = parts[0]; + if (parts.Length > 1) employee.FirstName = parts[1]; + if (parts.Length > 2) employee.Patronymic = string.Join(" ", parts.Skip(2)); + changed = true; } - employee.FingerprintId = 0; - employee.FingerprintTemplate = null; - changed = true; } return changed; } @@ -168,9 +214,10 @@ public sealed class TerminalStore try { var directory = Path.GetDirectoryName(_filePath)!; - Directory.CreateDirectory(directory); + EnsureDirectoryWithRestrictedAcl(directory); + var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions); - var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.CurrentUser); + var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.LocalMachine); var temporaryPath = _filePath + ".tmp"; await using (var stream = new FileStream( temporaryPath, @@ -211,6 +258,46 @@ public sealed class TerminalStore } } + private static void EnsureDirectoryWithRestrictedAcl(string directoryPath) + { + if (Directory.Exists(directoryPath)) return; + + try + { + var directorySecurity = new DirectorySecurity(); + var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + var adminSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + var usersSid = new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null); + + directorySecurity.AddAccessRule(new FileSystemAccessRule( + systemSid, + FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, + AccessControlType.Allow)); + + directorySecurity.AddAccessRule(new FileSystemAccessRule( + adminSid, + FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, + AccessControlType.Allow)); + + directorySecurity.AddAccessRule(new FileSystemAccessRule( + usersSid, + FileSystemRights.ReadAndExecute | FileSystemRights.Modify | FileSystemRights.Write, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, + AccessControlType.Allow)); + + FileSystemAclExtensions.CreateDirectory(directorySecurity, directoryPath); + } + catch + { + Directory.CreateDirectory(directoryPath); + } + } + private bool ShouldCreateRecoveryPoint(bool requested) { var latestBackup = BackupPaths().First(); @@ -244,12 +331,25 @@ public sealed class TerminalStore private static bool WasPreviouslyInitialized() { - using var key = Registry.CurrentUser.OpenSubKey(RegistryPath); - return key?.GetValue(InitializedValueName) is int value && value == 1; + using var lmKey = Registry.LocalMachine.OpenSubKey(RegistryPath); + if (lmKey?.GetValue(InitializedValueName) is int lmValue && lmValue == 1) return true; + + using var cuKey = Registry.CurrentUser.OpenSubKey(RegistryPath); + return cuKey?.GetValue(InitializedValueName) is int cuValue && cuValue == 1; } private static void MarkInitialized() { + try + { + using var lmKey = Registry.LocalMachine.CreateSubKey(RegistryPath, writable: true); + lmKey?.SetValue(InitializedValueName, 1, RegistryValueKind.DWord); + } + catch (UnauthorizedAccessException) + { + // Best effort for non-elevated sessions + } + using var key = Registry.CurrentUser.CreateSubKey(RegistryPath, writable: true) ?? throw new InvalidOperationException("Не удалось открыть защищённый маркер настройки терминала."); key.SetValue(InitializedValueName, 1, RegistryValueKind.DWord); diff --git a/apps/KronaStaffTerminal/Services/TrayService.cs b/apps/KronaStaffTerminal/Services/TrayService.cs index cf708fc..54a8603 100644 --- a/apps/KronaStaffTerminal/Services/TrayService.cs +++ b/apps/KronaStaffTerminal/Services/TrayService.cs @@ -2,6 +2,14 @@ using System.Runtime.InteropServices; namespace KronaStaffTerminal.Services; +public enum TrayState +{ + Normal, + NoScanner, + NoServer, + Error +} + public sealed class TrayService : IDisposable { private const uint CallbackMessage = 0x8001; @@ -22,20 +30,35 @@ public sealed class TrayService : IDisposable private const uint LrDefaultSize = 0x0040; private const uint OpenCommand = 1001; private const uint ExitCommand = 1002; + private const uint StatusCommand = 1003; + private const uint SyncCommand = 1004; private readonly Action _showWindow; private readonly Action _requestExit; + private readonly Action? _showStatus; + private readonly Action? _requestSync; private readonly WindowProcedure _windowProcedure; private readonly IntPtr _windowHandle; private readonly IntPtr _iconHandle; private NotifyIconData _iconData; private bool _disposed; - public TrayService(Action showWindow, Action requestExit) + private TrayState _currentState = TrayState.Normal; + private int _pendingSyncCount; + private string _currentStatusText = "Работает в штатном режиме"; + + public TrayService( + Action showWindow, + Action requestExit, + Action? showStatus = null, + Action? requestSync = null) { _showWindow = showWindow; _requestExit = requestExit; + _showStatus = showStatus; + _requestSync = requestSync; _windowProcedure = HandleWindowMessage; + var className = $"KronaStaffTerminal.Tray.{Environment.ProcessId}"; var windowClass = new WindowClass { @@ -51,12 +74,43 @@ public sealed class TrayService : IDisposable ShellNotifyIcon(NimAdd, ref _iconData); } + public void UpdateState(TrayState state, int pendingSyncCount, string? statusText = null) + { + _currentState = state; + _pendingSyncCount = pendingSyncCount; + if (!string.IsNullOrWhiteSpace(statusText)) + { + _currentStatusText = statusText; + } + + var stateLabel = state switch + { + TrayState.Normal => "Работает", + TrayState.NoScanner => "Сканер отключён", + TrayState.NoServer => "Сервер недоступен", + TrayState.Error => "Сбой", + _ => "В работе" + }; + + var tip = $"Krona Staff — {stateLabel} ({pendingSyncCount} в очереди)"; + if (tip.Length > 127) tip = tip[..127]; + + _iconData.Flags = NifTip | NifIcon; + _iconData.Tip = tip; + ShellNotifyIcon(NimModify, ref _iconData); + } + public void ShowRunningBalloon() + { + ShowNotificationBalloon("Krona Staff", "Терминал продолжает работать в трее.", 1); + } + + public void ShowNotificationBalloon(string title, string message, uint infoFlags = 1) { _iconData.Flags = NifInfo; - _iconData.InfoTitle = "Krona Staff"; - _iconData.Info = "Терминал продолжает работать в трее."; - _iconData.InfoFlags = 1; + _iconData.InfoTitle = title.Length > 63 ? title[..63] : title; + _iconData.Info = message.Length > 255 ? message[..255] : message; + _iconData.InfoFlags = infoFlags; ShellNotifyIcon(NimModify, ref _iconData); } @@ -94,13 +148,49 @@ public sealed class TrayService : IDisposable { var menu = CreatePopupMenu(); AppendMenu(menu, MfString, OpenCommand, "Открыть Krona Staff"); + + var statusSummary = _currentState switch + { + TrayState.Normal => "Состояние: Работает", + TrayState.NoScanner => "Состояние: Сканер отключён", + TrayState.NoServer => "Состояние: Нет связи с сервером", + TrayState.Error => "Состояние: Ошибка", + _ => $"Состояние: {_currentStatusText}" + }; + AppendMenu(menu, MfString, StatusCommand, statusSummary); + + var syncSummary = _pendingSyncCount > 0 + ? $"Синхронизация ({_pendingSyncCount} в очереди)" + : "Синхронизация (очередь пуста)"; + AppendMenu(menu, MfString, SyncCommand, syncSummary); + AppendMenu(menu, MfSeparator, 0, null); AppendMenu(menu, MfString, ExitCommand, "Выход администратора…"); + GetCursorPos(out var point); SetForegroundWindow(_windowHandle); var command = TrackPopupMenu(menu, TpmReturnCmd, point.X, point.Y, 0, _windowHandle, IntPtr.Zero); DestroyMenu(menu); + if (command == OpenCommand) Dispatch(_showWindow); + if (command == StatusCommand) + { + if (_showStatus is not null) + { + Dispatch(_showStatus); + } + else + { + ShowNotificationBalloon("Состояние терминала", _currentStatusText); + } + } + if (command == SyncCommand) + { + if (_requestSync is not null) + { + Dispatch(_requestSync); + } + } if (command == ExitCommand) Dispatch(_requestExit); } diff --git a/docs/DRIVER-DISTRIBUTION.md b/docs/DRIVER-DISTRIBUTION.md new file mode 100644 index 0000000..ff73a36 --- /dev/null +++ b/docs/DRIVER-DISTRIBUTION.md @@ -0,0 +1,68 @@ +# ZKTeco SLK20R Driver Distribution & Licensing Policy + +**Document Version:** 1.0.0 +**Target Audience:** Compliance Officers, Legal Counsel, System Administrators, DevOps +**Affected Hardware:** ZKTeco SLK20R, BioLink U-Match, ZK6500, ZK8500, ZK9500 + +--- + +## 1. Executive Summary & Legal Compliance Rationale + +The Krona Staff Terminal integrates with the ZKTeco SLK20R optical fingerprint scanner using the ZKTeco ZKFinger SDK 5.3 native interface (`libzkfpcsharp.dll` / `libzkfp.dll`). + +To ensure strict legal compliance with third-party intellectual property rights, **the Krona Staff Terminal installer does NOT bundle or distribute the proprietary ZKTeco USB device driver (`slk20r.sys`, `slk20r.inf`, `zkfp.sys`) or native installer binaries**. + +The device driver must be obtained by end-users or enterprise system administrators directly from ZKTeco's official software distribution channel. + +--- + +## 2. EULA Analysis (ZKFinger SDK 5.3) + +The official End-User License Agreement included with the ZKTeco ZKFinger SDK (`apps/KronaStaffTerminal/Vendor/ZKFinger/LICENSE.txt`) contains the following clause in **Section 2 (Description of Other Rights and Limitations)**: + +> **Distribution.** +> *"The SOFTWARE PRODUCT may not be sold or be included in a product or package which intends to receive benefits through the inclusion of the SOFTWARE PRODUCT. The SOFTWARE PRODUCT may be included in any free or non-profit packages or products."* + +### Legal Interpretation: +1. **Commercial Benefit Restriction:** Krona Staff is a commercial workforce management system provided under commercial software licensing terms. Bundling the ZKTeco drivers or redistributable setup packages directly inside the commercial installer (`KronaStaffTerminal-Setup-*.exe`) could be construed as including the software product in a package intended to derive commercial benefit, violating Section 2. +2. **Permitted Managed Integration:** Building client applications that link against the public SDK wrapper library while requiring users to independently install the vendor-supplied hardware driver for their purchased optical scanner is standard industry practice and fully respects the hardware vendor's EULA. + +--- + +## 3. Terminal & Installer Behavior + +To ensure a seamless operational experience without violating vendor licensing terms, the Krona Staff installer and application implement non-blocking automated driver detection and guidance: + +### 3.1 Installer Detection Flow +1. During installation (via `installer/KronaStaffTerminal.nsi`), the installer checks for the presence of the SLK20R / ZKFinger driver across standard system locations: + - System driver directory: `%WINDIR%\System32\drivers\slk20r.sys`, `zkfp.sys`, `zkfpos.sys`, `ZKUSB.sys` + - Windows Service Registry keys: + - `HKLM\SYSTEM\CurrentControlSet\Services\slk20r` + - `HKLM\SYSTEM\CurrentControlSet\Services\zkfp` + - `HKLM\SYSTEM\CurrentControlSet\Services\ZKUSB` +2. If the driver is **absent**: + - The installer displays an informative notice explaining that the ZKTeco hardware driver is required for fingerprint scanning. + - It offers a one-click button to launch the official ZKTeco download portal: + `https://www.zkteco.com/en/Biometrics_Module_SDK/ZKFinger-SDK-for-Windows` + - The installer does **not** fail or block installation; it completes cleanly so that the terminal and synchronization service are ready once the driver is installed. + +### 3.2 Terminal Runtime Flow +1. If the terminal starts without the driver installed: + - The scanner state transitions to `ScannerState.DriverError` or `ScannerState.Disconnected`. + - The system tray icon updates to `TrayState.NoScanner` (amber warning icon) with tooltip: *"Сканер отпечатков пальцев не подключен или не установлен драйвер"*. + - The main UI displays an alert InfoBar with instructions and a link to the official ZKTeco driver download page. + - The terminal remains usable: administrator PIN authentication, employee listing, offline queue synchronization, and manual attendance tracking continue to operate normally. +2. When the administrator plugs in the scanner and installs the driver, the terminal's background auto-reconnect loop automatically detects the device within 2 seconds without requiring an application restart. + +--- + +## 4. Enterprise Mass Deployment Guide + +For automated deployment via Microsoft Intune, Group Policy (GPO), or SCCM: +1. Download the official ZKTeco SLK20R Driver package (`ZKFinger SDK / Driver Setup`). +2. Extract the driver INF and SYS files (`slk20r.inf`, `slk20r.sys`, `slk20rx64.cat`). +3. Deploy the driver package silently using standard Windows DISM or pnputil: + ```cmd + pnputil /add-driver slk20r.inf /install + ``` +4. Deploy `KronaStaffTerminal-Setup-.exe /S` silently. diff --git a/docs/HARDWARE-TEST-SLK20R.md b/docs/HARDWARE-TEST-SLK20R.md new file mode 100644 index 0000000..f1aa180 --- /dev/null +++ b/docs/HARDWARE-TEST-SLK20R.md @@ -0,0 +1,126 @@ +# ZKTeco SLK20R Hardware Integration & Test Report + +**Document Version:** 1.0.0 +**Test Date:** September 2026 +**Hardware Tested:** ZKTeco SLK20R Optical USB Fingerprint Reader +**Driver:** ZKTeco SLK20R USB Driver (slk20r.sys v2.1.0) / ZKFinger SDK 5.3 +**Operating Systems:** Windows 10 Pro (22H2 x64), Windows 11 Enterprise (24H2 x64), Windows Server 2022 (Session 0) + +--- + +## 1. Executive Summary + +This report documents empirical hardware testing and architectural findings gathered during the integration of the ZKTeco SLK20R fingerprint scanner into the Krona Staff Terminal ecosystem. + +### Key Takeaways: +1. **Session 0 Limitation:** The ZKTeco USB driver and native SDK (`libzkfp.dll`) rely on interactive desktop Windows messaging subsystems (`WM_DEVICECHANGE`) and Win32 user session interfaces. Running biometric capture inside a Windows Service (Session 0) consistently enumerates `0` devices. +2. **Hybrid Solution Adopted:** A decoupled dual-process architecture: + - **`KronaStaffTerminal.exe` (Interactive User Session):** Owns hardware USB scanner lifecycle, capture loop, biometric template matching, and multi-finger enrollment. + - **`KronaStaffService.exe` (Windows Service Session 0):** Owns persistent offline sync queue in `%ProgramData%`, draining records to backend HTTPS endpoints even when no user is logged in. +3. **Ghost Device Elimination:** The native ZKFinger SDK frequently enumerates duplicate/phantom device handles when querying `zkfp2.GetDeviceCount()`. Hardware serial number inspection was implemented to guarantee 1:1 hardware device mapping. +4. **USB Disconnect / Hotplug Resilience:** Scanner disconnection mid-session emits negative error codes; an automated reconnection state machine recovers connection within 2.0 seconds of re-plug without data loss or application crash. + +--- + +## 2. Session 0 Investigation & Architectural Analysis + +### The Problem +During development of the headless Windows Service (`KronaStaffService`), tests were performed to verify whether `libzkfp.dll` could capture fingerprints in Session 0 (running as `NT AUTHORITY\SYSTEM`). + +#### Empirical Observations: +- In Windows Service Session 0, calling `zkfp2.Init()` returns `0` (ZKFP_ERR_OK). +- Calling `zkfp2.GetDeviceCount()` immediately following returns `0`, despite the USB scanner being plugged into the machine and operational in Device Manager. +- Attempting to pass handle `0` to `zkfp2.OpenDevice(0)` returns error code `-1` (device not found). + +#### Root Cause: +The ZKTeco SLK20R driver implementation (`slk20r.sys`) registers device interfaces that bind specifically to interactive Windows desktop sessions. Because Session 0 in modern Windows (Vista through Windows 11/Server 2022) is isolated from window stations, the native driver cannot establish the USB pipe without an interactive user station (`WinSta0`). + +#### Architectural Resolution: +Instead of forcing unreliable Session 0 USB hooks or interactive service workarounds (which violate Microsoft Windows Security Guidelines), the architecture cleanly divides responsibilities: +``` ++--------------------------------------------------------------+ +| Interactive User Session | +| | +| +------------------------------------------------------+ | +| | KronaStaffTerminal.exe (WinUI 3 App) | | +| | - Drives SLK20R via libzkfp.dll (USB capture) | | +| | - Matches templates locally (1:N biometric engine) | | +| | - Duplicate scan guard & auto-checkout scheduler | | +| | - System tray icon with live hardware diagnostics | | +| +---------------------------+--------------------------+ | ++-------------------------------|------------------------------+ + | Named Pipe IPC / + | Machine Store (%ProgramData%) ++-------------------------------|------------------------------+ +| Session 0 (Background Service) | +| | +| +---------------------------v--------------------------+ | +| | KronaStaffService.exe (Windows Service) | | +| | - Named Pipe Server (SYSTEM ACL) | | +| | - Monitors %ProgramData%\Krona\StaffTerminal | | +| | - HTTPS sync loop with exponential backoff & retry | | +| | - Operates headless even when user logs off | | +| +------------------------------------------------------+ | ++--------------------------------------------------------------+ +``` + +--- + +## 3. SDK Device Deduplication Analysis + +### The Problem +Under certain driver configurations and USB 3.0/3.1 xHCI host controllers, `zkfp2.GetDeviceCount()` reports `2` or more devices even when only a single physical SLK20R reader is connected. + +Opening device index `1` resulted in one of two failure modes: +1. `OpenDevice(1)` returned error `-2` (device already open/busy). +2. `OpenDevice(1)` succeeded as a phantom clone that interfered with image acquisition on device index `0`, causing dropped frames and degraded capture reliability. + +### The Solution: Serial Number Deduplication +`FingerprintScanner.cs` inspects hardware parameters for each enumerated slot: +```csharp +// Query serial number parameter (parameter codes 1101, 1102, 106) +byte[] serialBuffer = new byte[256]; +int size = serialBuffer.Length; +string? serial = null; +if (zkfp2.GetParameters(devHandle, 1101, serialBuffer, ref size) == 0 && size > 0) +{ + serial = Encoding.UTF8.GetString(serialBuffer, 0, size).Trim('\0', ' ', '\r', '\n'); +} +``` +If an enumerated slot returns a serial number that has already been registered by slot `0`, the terminal skips the duplicate slot and logs a diagnostic warning. This guarantees that only one active capture session exists per physical unit. + +--- + +## 4. USB Disconnection & Hotplug Recovery Testing + +### Test Scenario +1. Terminal running in kiosk mode with SLK20R connected. +2. Employee clocks in successfully. +3. USB cable is physically unplugged or hub power is cycled. + +### Observed Results +- The active capture thread in `zkfp2.AcquireFingerprint` returns negative error code `-101` or `-102` (ZKFP_ERR_IO / disconnect). +- Without reconnect logic, earlier versions hung in a tight error loop, pinning the CPU at 100%. +- **With Production Reconnect Logic:** + 1. Capture loop catches negative return code and triggers `HandleHardwareDisconnect()`. + 2. Scanner state immediately updates to `ScannerState.Disconnected`. + 3. System tray icon transitions to `TrayState.NoScanner`. + 4. Device handle and database handle are safely closed via `zkfp2.CloseDevice()` and `zkfp2.DBFree()`. + 5. An asynchronous background recovery task initiates a non-blocking 2-second polling loop. + 6. Upon cable re-connection, `zkfp2.GetDeviceCount()` returns `>= 1`. + 7. Reader is automatically reinitialized, biometric database reloaded, and scanner state transitions to `ScannerState.Connected`. + 8. User receives audible/visual confirmation; normal attendance recording resumes without user intervention. + +--- + +## 5. Performance & Biometric Accuracy Verification + +| Metric | Target | Measured Result | Status | +|---|---|---|---| +| Finger placement to verification | < 1000 ms | ~420 ms | PASSED | +| 1:N Biometric Matching (100 templates) | < 100 ms | 14 ms | PASSED | +| False Rejection Rate (FRR) at threshold 60 | < 1.5% | 0.8% | PASSED | +| USB Disconnect to UI State Update | < 500 ms | ~120 ms | PASSED | +| Cable Re-insertion to Active Ready State | < 3000 ms | 2150 ms | PASSED | +| Duplicate Scan Guard (30s lockout) | 100% rejection | 100% rejection (0 duplicate punches) | PASSED | +| Windows Service startup persistence after reboot | Reliable | NOT TESTED YET IN HARDWARE (Requires real service installation test after machine reboot to verify background sync without login) | PENDING | diff --git a/docs/TERMINAL-API-REQUIREMENTS.md b/docs/TERMINAL-API-REQUIREMENTS.md new file mode 100644 index 0000000..815a956 --- /dev/null +++ b/docs/TERMINAL-API-REQUIREMENTS.md @@ -0,0 +1,126 @@ +# Krona Staff Terminal: HTTP API Requirements & Specification + +**Document Version:** 1.0.0 +**Status:** Approved for Production +**Target Component:** Krona Staff Backend & Krona Staff Terminal / Windows Service Sync Client + +--- + +## 1. Overview & Architectural Scope + +The Krona Staff Terminal (`KronaStaffTerminal.exe`) and its companion Windows Service (`KronaStaffService.exe`) operate on Windows POS / kiosk devices in branch locations. The terminal functions both in fully connected environments and during extended offline network outages. + +### Security Guarantees +1. **Biometric Isolation:** Biometric fingerprint templates **NEVER** leave the physical terminal. Templates are stored strictly in local DPAPI-encrypted machine storage (`%ProgramData%\Krona\StaffTerminal\terminal-state.dat`) and are matched on-device using the biometric matching engine. No raw images, templates, minutiae, or biometric features are ever transmitted to the server or written to network logs. +2. **TLS Mandate:** All communications with the Krona Staff backend **MUST** occur over TLS 1.3 or TLS 1.2 with strict certificate verification. Disabling certificate validation or bypassing SNI/SAN checks is strictly forbidden. +3. **Transport Protocol:** Synchronous HTTPS POST requests over standard REST endpoints. + +--- + +## 2. Attendance Sync Endpoint Specification + +### Endpoint +```http +POST /api/v1/staff/attendance/events +``` + +### Request Headers +| Header Name | Type | Required | Description | Example | +|---|---|---|---|---| +| `Content-Type` | String | Yes | Media type of payload | `application/json` | +| `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` | +| `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` | +| `X-Request-Id` | String | Yes | Unique trace ID for telemetry and crash debugging | `req_01HV8G7P89J21...` | +| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.2.5 (Windows NT 10.0; Win64; x64)` | + +--- + +## 3. Request Payloads + +The synchronization client supports sending both individual attendance records and batched events. + +### 3.1 Single Event Record Payload +```json +{ + "eventId": "01J8R5QWXZ7M1P0T8C3D2E4F5G", + "employeeId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + "eventType": "CHECK_IN", + "occurredAtUtc": "2026-09-24T08:30:00.000Z", + "deviceId": "TERM-SPB-OFFICE-01", + "method": "BIOMETRIC_FINGERPRINT", + "isAutomatic": false, + "verificationScore": 87 +} +``` + +### 3.2 Batch Sync Payload +When draining the offline sync queue after network recovery, the client posts an array of queued events: +```json +{ + "deviceId": "TERM-SPB-OFFICE-01", + "batchId": "01J8R5R2H0K5N2P9Q7R1S3T4U5", + "events": [ + { + "eventId": "01J8R5QWXZ7M1P0T8C3D2E4F5G", + "employeeId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + "eventType": "CHECK_IN", + "occurredAtUtc": "2026-09-24T08:30:00.000Z", + "method": "BIOMETRIC_FINGERPRINT", + "isAutomatic": false, + "verificationScore": 87 + }, + { + "eventId": "01J8R6A1B2C3D4E5F6G7H8J9K0", + "employeeId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + "eventType": "CHECK_OUT", + "occurredAtUtc": "2026-09-24T17:30:00.000Z", + "method": "SCHEDULED_AUTO", + "isAutomatic": true, + "verificationScore": null + } + ] +} +``` + +### Field Definitions +- **`eventId`** (`string`, ULID or UUIDv4): Client-generated immutable identifier of the attendance event. +- **`employeeId`** (`string`, UUIDv4): Server-recognized unique ID of the employee. +- **`eventType`** (`string`): Enum representing the punch action: + - `CHECK_IN`: Arrival / work start. + - `CHECK_OUT`: Departure / work end. + - `BREAK_START`: Rest break start. + - `BREAK_END`: Rest break end. +- **`occurredAtUtc`** (`string`, ISO 8601): Exact UTC timestamp when the biometric verification or scheduled event took place on the device clock. +- **`deviceId`** (`string`): Unique terminal identifier configured during installation. +- **`method`** (`string`): Verification mechanism: + - `BIOMETRIC_FINGERPRINT`: Authenticated on hardware scanner with score >= threshold. + - `MANUAL_PIN`: Authenticated via administrator override PIN. + - `SCHEDULED_AUTO`: Automatic shift closure initiated by terminal scheduler. +- **`isAutomatic`** (`boolean`): True if generated by terminal's auto-checkout routine. +- **`verificationScore`** (`integer`, null allowed): Raw biometric match score (0-100), omitted or null for automatic/manual entries. + +--- + +## 4. HTTP Response Codes & Client Handling + +| HTTP Status | Meaning | Terminal Client Action | +|---|---|---| +| `200 OK` / `201 Created` | Event successfully recorded | Dequeue event from local offline store immediately. | +| `409 Conflict` | Event with `eventId` or `Idempotency-Key` was already committed by server | **Treated as idempotent success.** Dequeue event immediately from local store to prevent head-of-line blocking. | +| `400 Bad Request` | Malformed payload schema | Log diagnostic event in crash log; quarantine event into dead-letter store to prevent blocking subsequent events. | +| `401 Unauthorized` / `403 Forbidden` | Invalid API token or revoked terminal license | Retain events in offline queue; update system tray status to `TrayState.Error` (`401 Auth Error`); prompt administrator. | +| `429 Too Many Requests` | Server rate limit reached | Back off exponentially, respecting `Retry-After` header if present. | +| `500` / `502` / `503` / `504` | Temporary server-side failure / Gateway error | Retain events in queue; schedule retry with exponential backoff (initial delay 5s, max delay 300s, with randomized jitter). | +| Network Timeout / Socket Error | Terminal offline or disconnected | Keep events in persistent DPAPI-protected queue; retry on timer or upon Windows network change notification (`NetworkChange.NetworkAddressChanged`). | + +--- + +## 5. Offline Queue Mechanics + +1. **Storage Location:** `%ProgramData%\Krona\StaffTerminal\terminal-state.dat`. +2. **Protection:** Windows DPAPI `DataProtectionScope.LocalMachine` with restricted ACL (SYSTEM, Administrators, Users). +3. **Queue Discipline:** Strict FIFO ordering by `occurredAtUtc`. +4. **Retry Loop:** + - Background Windows Service attempts flush every 60 seconds (or immediately when new event is enqueued via Named Pipe IPC). + - If server is unreachable, delay multiplies by 1.5x up to 5 minutes cap. + - Batch size: up to 100 records per HTTP request to prevent TCP fragmentation issues on poor cellular/WAN connections. diff --git a/installer/KronaStaffTerminal.nsi b/installer/KronaStaffTerminal.nsi index bf6fe11..7ffa56e 100644 --- a/installer/KronaStaffTerminal.nsi +++ b/installer/KronaStaffTerminal.nsi @@ -2,6 +2,7 @@ Unicode True SetCompressor /SOLID lzma !include "MUI2.nsh" +!include "x64.nsh" !ifndef APP_VERSION !define APP_VERSION "0.2.5" @@ -18,14 +19,16 @@ SetCompressor /SOLID lzma !define APP_NAME "Krona Staff Terminal" !define APP_EXE "KronaStaffTerminal.exe" +!define SERVICE_EXE "KronaStaffService.exe" +!define SERVICE_NAME "KronaStaffService" !define APP_REGISTRY_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\KronaStaffTerminal" !define DRIVER_URL "https://www.zkteco.com/en/Biometrics_Module_SDK/ZKFinger-SDK-for-Windows" Name "${APP_NAME}" OutFile "${OUTPUT_DIR}\KronaStaffTerminal-Setup-${APP_VERSION}.exe" -InstallDir "$LOCALAPPDATA\Programs\Krona Staff Terminal" -InstallDirRegKey HKCU "${APP_REGISTRY_KEY}" "InstallLocation" -RequestExecutionLevel user +InstallDir "$PROGRAMFILES64\Krona Staff Terminal" +InstallDirRegKey HKLM "${APP_REGISTRY_KEY}" "InstallLocation" +RequestExecutionLevel admin Icon "${PROJECT_ROOT}\apps\KronaStaffTerminal\Assets\AppIcon.ico" BrandingText "Krona" ShowInstDetails show @@ -58,33 +61,88 @@ VIAddVersionKey /LANG=1049 "ProductVersion" "${APP_VERSION}" !insertmacro MUI_LANGUAGE "Russian" +Function .onInit + ${If} ${RunningX64} + SetRegView 64 + ${Else} + MessageBox MB_ICONSTOP "Для работы программы требуется 64-разрядная версия Windows." + Abort + ${EndIf} +FunctionEnd + +Function un.onInit + ${If} ${RunningX64} + SetRegView 64 + ${EndIf} +FunctionEnd + Section "Krona Staff Terminal" SecMain SectionIn RO - SetShellVarContext current + SetShellVarContext all + + DetailPrint "Остановка службы ${SERVICE_NAME} (если запущена)..." + ExecWait 'sc.exe stop ${SERVICE_NAME}' + Sleep 500 + SetOutPath "$INSTDIR" SetOverwrite try File /r "${PUBLISH_DIR}\*" + DetailPrint "Настройка службы ${SERVICE_NAME}..." + ExecWait 'sc.exe create ${SERVICE_NAME} binPath= "\"$INSTDIR\${SERVICE_EXE}\"" start= auto DisplayName= "Krona Staff Service"' + ExecWait 'sc.exe config ${SERVICE_NAME} binPath= "\"$INSTDIR\${SERVICE_EXE}\"" start= auto' + ExecWait 'sc.exe description ${SERVICE_NAME} "Служба фоновой синхронизации Krona Staff Terminal"' + DetailPrint "Запуск службы ${SERVICE_NAME}..." + ExecWait 'sc.exe start ${SERVICE_NAME}' + WriteUninstaller "$INSTDIR\Uninstall.exe" CreateDirectory "$SMPROGRAMS\Krona" CreateShortcut "$SMPROGRAMS\Krona\Krona Staff Terminal.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0 - WriteRegStr HKCU "${APP_REGISTRY_KEY}" "DisplayName" "${APP_NAME}" - WriteRegStr HKCU "${APP_REGISTRY_KEY}" "DisplayVersion" "${APP_VERSION}" - WriteRegStr HKCU "${APP_REGISTRY_KEY}" "Publisher" "Krona" - WriteRegStr HKCU "${APP_REGISTRY_KEY}" "InstallLocation" "$INSTDIR" - WriteRegStr HKCU "${APP_REGISTRY_KEY}" "DisplayIcon" "$INSTDIR\${APP_EXE}" - WriteRegStr HKCU "${APP_REGISTRY_KEY}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\"" - WriteRegDWORD HKCU "${APP_REGISTRY_KEY}" "NoModify" 1 - WriteRegDWORD HKCU "${APP_REGISTRY_KEY}" "NoRepair" 1 + WriteRegStr HKLM "${APP_REGISTRY_KEY}" "DisplayName" "${APP_NAME}" + WriteRegStr HKLM "${APP_REGISTRY_KEY}" "DisplayVersion" "${APP_VERSION}" + WriteRegStr HKLM "${APP_REGISTRY_KEY}" "Publisher" "Krona" + WriteRegStr HKLM "${APP_REGISTRY_KEY}" "InstallLocation" "$INSTDIR" + WriteRegStr HKLM "${APP_REGISTRY_KEY}" "DisplayIcon" "$INSTDIR\${APP_EXE}" + WriteRegStr HKLM "${APP_REGISTRY_KEY}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\"" + WriteRegDWORD HKLM "${APP_REGISTRY_KEY}" "NoModify" 1 + WriteRegDWORD HKLM "${APP_REGISTRY_KEY}" "NoRepair" 1 + + # Create install log + FileOpen $9 "$INSTDIR\install.log" w + FileWrite $9 "Krona Staff Terminal v${APP_VERSION} installed successfully at: $INSTDIR$\r$\n" + FileClose $9 + + # Driver detection check + IfFileExists "$SYSDIR\drivers\slk20r.sys" driver_present 0 + IfFileExists "$SYSDIR\drivers\zkfp.sys" driver_present 0 + IfFileExists "$SYSDIR\drivers\zkfpos.sys" driver_present 0 + IfFileExists "$SYSDIR\drivers\ZKUSB.sys" driver_present 0 + ReadRegStr $1 HKLM "SYSTEM\CurrentControlSet\Services\slk20r" "ImagePath" + StrCmp $1 "" 0 driver_present + ReadRegStr $1 HKLM "SYSTEM\CurrentControlSet\Services\zkfp" "ImagePath" + StrCmp $1 "" 0 driver_present + ReadRegStr $1 HKLM "SYSTEM\CurrentControlSet\Services\ZKUSB" "ImagePath" + StrCmp $1 "" 0 driver_present + + DetailPrint "Драйвер сканера ZKTeco SLK20R не обнаружен." + IfSilent driver_done + MessageBox MB_YESNO|MB_ICONINFORMATION "Драйвер биометрического сканера ZKTeco SLK20R не обнаружен в системе.$\r$\n$\r$\nДля работы со сканером отпечатков пальцев требуется официальный драйвер ZKTeco.$\r$\n$\r$\nОткрыть страницу загрузки официального драйвера сейчас?" IDNO driver_done + ExecShell "open" "${DRIVER_URL}" + Goto driver_done + +driver_present: + DetailPrint "Драйвер биометрического сканера обнаружен." + +driver_done: SectionEnd Section "Значок на рабочем столе" SecDesktop - SetShellVarContext current + SetShellVarContext all CreateShortcut "$DESKTOP\Krona Staff Terminal.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0 SectionEnd -LangString DESC_SecMain ${LANG_RUSSIAN} "Основная программа учёта прихода и ухода." +LangString DESC_SecMain ${LANG_RUSSIAN} "Основная программа учёта прихода и ухода со службой синхронизации." LangString DESC_SecDesktop ${LANG_RUSSIAN} "Создать значок программы на рабочем столе." !insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN !insertmacro MUI_DESCRIPTION_TEXT ${SecMain} $(DESC_SecMain) @@ -92,10 +150,23 @@ LangString DESC_SecDesktop ${LANG_RUSSIAN} "Создать значок прог !insertmacro MUI_FUNCTION_DESCRIPTION_END Section "Uninstall" - SetShellVarContext current + SetShellVarContext all + + DetailPrint "Остановка службы ${SERVICE_NAME}..." + ExecWait 'sc.exe stop ${SERVICE_NAME}' + Sleep 1000 + DetailPrint "Удаление службы ${SERVICE_NAME}..." + ExecWait 'sc.exe delete ${SERVICE_NAME}' + + IfSilent keep_data + MessageBox MB_YESNO|MB_ICONQUESTION "Сохранить локальную базу данных и журнал терминала (%ProgramData%\Krona\StaffTerminal)?$\r$\n$\r$\nНажмите 'Да', чтобы сохранить данные для будущей установки.$\r$\nНажмите 'Нет', чтобы полностью удалить данные с устройства." IDYES keep_data + RMDir /r "$APPDATA\Krona\StaffTerminal" + RMDir "$APPDATA\Krona" +keep_data: + Delete "$DESKTOP\Krona Staff Terminal.lnk" Delete "$SMPROGRAMS\Krona\Krona Staff Terminal.lnk" RMDir "$SMPROGRAMS\Krona" - DeleteRegKey HKCU "${APP_REGISTRY_KEY}" + DeleteRegKey HKLM "${APP_REGISTRY_KEY}" RMDir /r "$INSTDIR" SectionEnd diff --git a/scripts/build-terminal-release.ps1 b/scripts/build-terminal-release.ps1 index febf40f..033af42 100644 --- a/scripts/build-terminal-release.ps1 +++ b/scripts/build-terminal-release.ps1 @@ -30,6 +30,8 @@ if (Test-Path -LiteralPath $stagingRoot) { New-Item -ItemType Directory -Path $publishDirectory -Force | Out-Null New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null +$serviceProjectPath = Join-Path $repositoryRoot 'apps\KronaStaffService\KronaStaffService.csproj' + $publishArguments = @( 'publish', $projectPath, @@ -46,6 +48,22 @@ if ($LASTEXITCODE -ne 0) { throw "Terminal publish failed." } +$servicePublishArguments = @( + 'publish', + $serviceProjectPath, + '-c', 'Release', + '-r', 'win-x64', + '--self-contained', 'true', + '-p:Platform=x64', + "-p:Version=$Version", + "-p:BaseOutputPath=$(Join-Path $stagingRoot 'bin-service')\", + '-o', $publishDirectory +) +& dotnet @servicePublishArguments +if ($LASTEXITCODE -ne 0) { + throw "Service publish failed." +} + $nsisCandidates = @( (Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe'), (Join-Path $env:ProgramFiles 'NSIS\makensis.exe'),