krona-staff/apps/KronaStaffService/KronaStaffWorker.cs
Ochenstarik 3e2054480c
Some checks are pending
ci / verify (push) Waiting to run
fix(push): isolate employee data and preserve platform outbox
2026-09-26 12:31:10 +07:00

278 lines
11 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}