using System.Security.Cryptography; using System.Text.Json; using KronaStaffTerminal.Models; namespace KronaStaffTerminal.Services; public sealed class TerminalStore { private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray(); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private readonly SemaphoreSlim _saveGate = new(1, 1); private readonly string _filePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Krona", "StaffTerminal", "terminal-state.dat"); public async Task LoadAsync() { var sourcePath = File.Exists(_filePath) ? _filePath : FindLegacyPackagedState(); if (sourcePath is null) return new TerminalState(); try { var state = await ReadStateAsync(sourcePath); var migrated = MigrateFingerprints(state); if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) { await SaveAsync(state); } return state; } catch (Exception primaryException) when (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) { var corruptPath = _filePath + $".corrupt-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}"; try { File.Move(_filePath, corruptPath, false); } catch (Exception moveException) { throw new InvalidDataException( "Хранилище терминала повреждено и не может быть сохранено отдельно. Работа заблокирована, чтобы не потерять данные.", new AggregateException(primaryException, moveException)); } foreach (var backupPath in BackupPaths()) { if (!File.Exists(backupPath)) continue; try { var recovered = await ReadStateAsync(backupPath); MigrateFingerprints(recovered); await SaveAsync(recovered); return recovered; } catch (Exception backupException) { CrashLogService.Write(backupException); } } throw new InvalidDataException( $"Хранилище терминала повреждено. Исходный файл сохранён как {Path.GetFileName(corruptPath)}. Работа заблокирована до восстановления резервной копии.", primaryException); } } private static async Task ReadStateAsync(string path) { var encrypted = await File.ReadAllBytesAsync(path); var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser); return JsonSerializer.Deserialize(clear, JsonOptions) ?? throw new InvalidDataException("Файл состояния не содержит данных."); } private static bool MigrateFingerprints(TerminalState state) { var changed = false; foreach (var employee in state.Employees) { employee.Fingerprints ??= []; 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" }); } employee.FingerprintId = 0; employee.FingerprintTemplate = null; changed = true; } return changed; } public async Task SaveAsync(TerminalState state) { await _saveGate.WaitAsync(); try { var directory = Path.GetDirectoryName(_filePath)!; Directory.CreateDirectory(directory); var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions); var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.CurrentUser); var temporaryPath = _filePath + ".tmp"; await using (var stream = new FileStream( temporaryPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous | FileOptions.WriteThrough)) { await stream.WriteAsync(encrypted); await stream.FlushAsync(); stream.Flush(true); } RotateBackups(); File.Move(temporaryPath, _filePath, true); } finally { _saveGate.Release(); } } private IEnumerable BackupPaths() { for (var index = 1; index <= 3; index++) yield return _filePath + $".bak{index}"; } private void RotateBackups() { var backups = BackupPaths().ToArray(); for (var index = backups.Length - 1; index > 0; index--) { if (File.Exists(backups[index - 1])) File.Copy(backups[index - 1], backups[index], true); } if (File.Exists(_filePath)) File.Copy(_filePath, backups[0], true); } private static string? FindLegacyPackagedState() { var packagesPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Packages"); if (!Directory.Exists(packagesPath)) return null; return Directory.EnumerateDirectories(packagesPath, "223A9635-262B-4298-9F19-1D2FE0A6B5F7_*") .Select(directory => Path.Combine(directory, "LocalCache", "Local", "Krona", "StaffTerminal", "terminal-state.dat")) .FirstOrDefault(File.Exists); } }