278 lines
11 KiB
C#
278 lines
11 KiB
C#
using System.Collections.Concurrent;
|
||
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<KronaStaffWorker> _logger;
|
||
private readonly TerminalStore _store;
|
||
private readonly AttendanceSyncService _syncService;
|
||
private readonly ILicenseStatusPort _licensePort;
|
||
private readonly SemaphoreSlim _syncGate = new(1, 1);
|
||
private readonly ConcurrentDictionary<string, long> _acceptedIpcNonces = new();
|
||
private TerminalIpcServer? _ipcServer;
|
||
private bool _scannerSession0Supported;
|
||
private string _scannerDiagnostic = "";
|
||
private DateTimeOffset? _lastSyncUtc;
|
||
private string? _lastSyncError;
|
||
|
||
public KronaStaffWorker(
|
||
ILogger<KronaStaffWorker> 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<AttendanceSyncResult> 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<IpcResponse> HandleIpcRequestAsync(IpcRequest request)
|
||
{
|
||
try
|
||
{
|
||
var authenticationState = await _store.LoadAsync();
|
||
var now = DateTimeOffset.UtcNow;
|
||
if (!IpcRequestAuthenticator.Verify(
|
||
request, authenticationState.Settings.IpcAuthenticationKey, now))
|
||
{
|
||
return new IpcResponse { Success = false, ErrorMessage = "Unauthorized request" };
|
||
}
|
||
foreach (var nonce in _acceptedIpcNonces.Where(item => item.Value < now.AddMinutes(-1).ToUnixTimeSeconds()))
|
||
_acceptedIpcNonces.TryRemove(nonce.Key, out _);
|
||
if (!_acceptedIpcNonces.TryAdd(request.Nonce, request.IssuedAtUnixSeconds))
|
||
return new IpcResponse { Success = false, ErrorMessage = "Replayed request" };
|
||
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<AttendanceRecord>(request.Payload);
|
||
if (record is null)
|
||
{
|
||
return new IpcResponse { Success = false, ErrorMessage = "Invalid attendance record JSON" };
|
||
}
|
||
|
||
if (!System.Text.RegularExpressions.Regex.IsMatch(record.Id, "^[0-9A-HJKMNP-TV-Z]{26}$") ||
|
||
!System.Text.RegularExpressions.Regex.IsMatch(record.EmployeeId, "^[0-9A-HJKMNP-TV-Z]{26}$") ||
|
||
record.EventType is not ("Приход" or "Уход" or "Уход автоматически") ||
|
||
record.OccurredAtUtc < DateTimeOffset.UtcNow.AddDays(-180) ||
|
||
record.OccurredAtUtc > DateTimeOffset.UtcNow.AddMinutes(5) ||
|
||
record.ReaderIndex < 0)
|
||
{
|
||
return new IpcResponse { Success = false, ErrorMessage = "Invalid attendance record" };
|
||
}
|
||
|
||
await _syncGate.WaitAsync();
|
||
try
|
||
{
|
||
var state = await _store.LoadAsync();
|
||
var employee = state.Employees.FirstOrDefault(item => item.Id == record.EmployeeId);
|
||
if (employee is null || employee.IsBlocked)
|
||
{
|
||
return new IpcResponse { Success = false, ErrorMessage = "Employee is unavailable" };
|
||
}
|
||
record.EmployeeName = employee.DisplayName;
|
||
record.ServerSynced = false;
|
||
record.ServerSyncError = null;
|
||
record.NotificationDelivered = false;
|
||
record.NotificationError = null;
|
||
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<IpcServiceStatus> 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.3.2",
|
||
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);
|
||
}
|
||
}
|