using System.Text.Json; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; using MBN_STOCK_WEBVIEW.Infrastructure; namespace MBN_STOCK_WEBVIEW; public sealed partial class MainWindow { private const string OperatorDataDirectoryEnvironmentVariable = "MBN_STOCK_OPERATOR_DATA_DIRECTORY"; private const int MaximumManualValueLength = 256; private const string ManualOperatorCorrelationQuarantineMessage = "An operator-data file write result could not be confirmed. Restart the app and reconcile the trusted files before another write."; private readonly SemaphoreSlim _manualOperatorDataGate = new(1, 1); private S5025TrustedManualFileStore? _manualNetSellStore; private ViTrustedManualFileStore? _viManualListStore; private LegacyManualOperatorDataImporter? _legacyManualOperatorDataImporter; private string? _manualOperatorDataInitializationError; private string? _manualOperatorDataInitializationCode; private int _manualOperatorWriteInFlight; private int _manualOperatorWriteQuarantined; private int _manualOperatorBrowserGeneration; private string? _manualOperatorWriteQuarantineMessage = ManualOperatorCorrelationQuarantineMessage; private void InitializeManualOperatorDataRuntime() { try { var configuredDirectory = Environment.GetEnvironmentVariable( OperatorDataDirectoryEnvironmentVariable); if (!string.IsNullOrWhiteSpace(configuredDirectory)) { _manualOperatorDataInitializationCode = "UNSAFE_CONFIGURATION"; _manualOperatorDataInitializationError = "외부 수동 데이터 경로는 TOCTOU 안전성을 보장할 수 없어 비활성화되었습니다. 앱 전용 로컬 운영 데이터 폴더를 사용하세요."; return; } var directory = TrustedManualDirectory.PreparePrivateWritableDirectory(Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MBN_STOCK_WEBVIEW", "OperatorData")); var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(directory); if (!inspection.IsReady) { (_manualOperatorDataInitializationCode, _manualOperatorDataInitializationError) = inspection.State switch { LegacyManualOperatorDataRuntimeState.InterruptedImport => ( "IMPORT_INTERRUPTED", "이전 원본 수동 데이터 가져오기가 중단되었습니다. FSell/VI 파일은 열지 않았습니다. 다시 시작하기 전에 진행 표식, 완료 표식, 데이터 파일 4개를 확인하세요."), _ => ( "IMPORTED_STATE_INVALID", "원본 수동 데이터 완료 표식과 파일 4개가 검증된 한 세트를 이루지 않습니다. FSell/VI 파일은 열지 않았습니다. 다시 시작하기 전에 앱 전용 운영 데이터 폴더를 확인하세요.") }; return; } _manualNetSellStore = new S5025TrustedManualFileStore(directory); _viManualListStore = new ViTrustedManualFileStore(directory); _legacyManualOperatorDataImporter = new LegacyManualOperatorDataImporter( directory, new FixedLegacyManualOperatorDataSource()); } catch { _legacyManualOperatorDataImporter?.Dispose(); _viManualListStore?.Dispose(); _manualNetSellStore?.Dispose(); _legacyManualOperatorDataImporter = null; _viManualListStore = null; _manualNetSellStore = null; _manualOperatorDataInitializationCode = "INITIALIZATION_FAILED"; _manualOperatorDataInitializationError = "수동 송출 데이터 저장소를 초기화하지 못했습니다. 로컬 운영 데이터 설정을 확인하세요."; } } private void HandleManualOperatorStatusRequest(JsonElement payload) { if (payload.ValueKind != JsonValueKind.Object || !HasOnlyProperties(payload, "requestId") || !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId)) { PostMessage("manual-operator-data-error", new { requestId = SafeManualOperatorRequestId(payload), operation = "status", code = "INVALID_REQUEST", message = "올바르지 않은 수동 데이터 상태 요청입니다.", retryable = false, outcomeUnknown = false }); return; } PostMessage("manual-operator-data-status", new { requestId, available = _manualNetSellStore is not null && _viManualListStore is not null, code = _manualOperatorDataInitializationCode ?? "READY", writeQuarantined = IsManualOperatorWriteQuarantined(), message = IsManualOperatorWriteQuarantined() ? GetOperatorMutationQuarantineMessage( _manualOperatorWriteQuarantineMessage ?? ManualOperatorCorrelationQuarantineMessage) : _manualOperatorDataInitializationError }); } private async Task HandleLegacyManualOperatorImportAsync(JsonElement payload) { const string operation = "import-legacy-manual-data"; if (payload.ValueKind != JsonValueKind.Object || !HasOnlyProperties(payload, "requestId") || !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId)) { PostManualOperatorError( SafeManualOperatorRequestId(payload), operation, "INVALID_REQUEST", "올바르지 않은 원본 수동 데이터 가져오기 요청입니다.", retryable: false, outcomeUnknown: false); return; } var importer = _legacyManualOperatorDataImporter; if (importer is null || _manualNetSellStore is null || _viManualListStore is null) { PostManualOperatorUnavailable(requestId, operation); return; } if (IsManualOperatorWriteQuarantined()) { PostManualOperatorQuarantine(requestId, operation); return; } if (!await TryEnterManualMutationGatesAsync(requestId, operation)) { return; } var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration); Interlocked.Exchange(ref _manualOperatorWriteInFlight, 1); try { var result = await importer.ImportAsync(_lifetimeCancellation.Token); if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) { QuarantineManualOperatorWrites( "원본 수동 데이터 가져오기 중 화면 상관관계가 사라졌습니다. 가져오기 marker와 4개 파일을 확인하고 앱을 다시 시작하기 전까지 작업을 반복하지 마세요."); return; } PostMessage("legacy-manual-operator-import-result", new { requestId, markerVersion = result.MarkerVersion, sourceSha256 = result.SourceSha256, importedAt = result.ImportedAt, netSellRowCount = result.NetSellRowCount, viItemCount = result.ViItemCount }); } catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) { // A pre-commit cancellation creates nothing. Once commit begins the // importer completes or rolls back without observing cancellation. } catch (LegacyManualOperatorImportException exception) { if (exception.OutcomeUnknown) { QuarantineManualOperatorWrites( "원본 수동 데이터 가져오기를 안전하게 되돌렸는지 확인할 수 없습니다. 4개 파일과 marker를 직접 확인하고 앱을 다시 시작하기 전까지 작업을 반복하지 마세요."); PostManualOperatorQuarantine(requestId, operation); return; } var (code, message) = exception.Failure switch { LegacyManualOperatorImportFailure.SourceUnavailable => ( "SOURCE_UNAVAILABLE", "원본 프로젝트의 FSell/VI 데이터 4개 파일을 찾을 수 없습니다."), LegacyManualOperatorImportFailure.SourceInvalid => ( "SOURCE_INVALID", "원본 FSell/VI 데이터가 CP949 원본 형식 또는 크기 제한을 충족하지 않습니다."), LegacyManualOperatorImportFailure.AlreadyImported => ( "ALREADY_IMPORTED", "이 앱 전용 폴더에는 이미 완료 marker가 있어 다시 가져오지 않습니다."), LegacyManualOperatorImportFailure.DestinationNotEmpty => ( "DESTINATION_NOT_EMPTY", "앱 전용 폴더에 기존 FSell/VI 데이터가 있어 아무 파일도 덮어쓰지 않았습니다."), LegacyManualOperatorImportFailure.InterruptedImport => ( "IMPORT_INTERRUPTED", "이전 가져오기의 진행 표식이 남아 있습니다. 완료 표식과 파일 4개를 확인하기 전에는 다시 시작하거나 반복하지 마세요."), _ => ( "IMPORT_FAILED", "원본 수동 데이터 가져오기에 실패했고 생성된 파일은 되돌렸습니다. 자동으로 재시도하지 않습니다.") }; PostManualOperatorError( requestId, operation, code, message, retryable: false, outcomeUnknown: false); } catch { QuarantineManualOperatorWrites( "원본 수동 데이터 가져오기 결과를 확인할 수 없습니다. 4개 파일과 marker를 직접 확인하고 앱을 다시 시작하기 전까지 작업을 반복하지 마세요."); PostManualOperatorQuarantine(requestId, operation); } finally { Interlocked.Exchange(ref _manualOperatorWriteInFlight, 0); ExitManualMutationGates(); } } private async Task HandleManualNetSellReadAsync(JsonElement payload) { if (!TryParseAudienceRequest(payload, out var requestId, out var audience)) { PostManualOperatorError( requestId, "read-net-sell", "INVALID_REQUEST", "올바르지 않은 수동 순매도 조회 요청입니다.", retryable: false, outcomeUnknown: false); return; } var store = _manualNetSellStore; if (store is null) { PostManualOperatorUnavailable(requestId, "read-net-sell"); return; } var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration); await _manualOperatorDataGate.WaitAsync(_lifetimeCancellation.Token); try { var rows = await store.ReadAsync(audience, _lifetimeCancellation.Token); if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) { return; } PostMessage("manual-net-sell-data", new { requestId, audience = ToWireValue(audience), rows = rows.Select(row => new { row.LeftName, row.LeftAmount, row.RightName, row.RightAmount }) }); } catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) { // Window shutdown owns cancellation and no stale browser response is posted. } catch (S5025TrustedManualDataException) { PostManualOperatorError( requestId, "read-net-sell", "DATA_INVALID", "수동 순매도 데이터 파일이 없거나 형식이 올바르지 않습니다.", retryable: false, outcomeUnknown: false); } finally { _manualOperatorDataGate.Release(); } } private async Task HandleManualNetSellWriteAsync(JsonElement payload) { if (!TryParseManualNetSellWrite( payload, out var requestId, out var audience, out var rows)) { PostManualOperatorError( requestId, "save-net-sell", "INVALID_REQUEST", "수동 순매도 입력은 5행의 검증된 값이어야 합니다.", retryable: false, outcomeUnknown: false); return; } var store = _manualNetSellStore; if (store is null) { PostManualOperatorUnavailable(requestId, "save-net-sell"); return; } if (IsManualOperatorWriteQuarantined()) { PostManualOperatorQuarantine(requestId, "save-net-sell"); return; } if (!await TryEnterManualMutationGatesAsync(requestId, "save-net-sell")) { return; } var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration); if (!CanStartOperatorMutation(IsManualOperatorWriteQuarantined())) { ExitManualMutationGates(); PostManualOperatorError( requestId, "save-net-sell", "PLAYOUT_ACTIVE", "Operator data cannot be changed unless playout is verifiably idle.", retryable: true, outcomeUnknown: false); return; } Interlocked.Exchange(ref _manualOperatorWriteInFlight, 1); try { await store.WriteAsync(audience, rows, _lifetimeCancellation.Token); if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) { QuarantineManualOperatorWrites( "수동 순매도 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); return; } PostManualNetSellWriteResult(requestId, audience, recovered: false); } catch (Exception exception) when (exception is S5025TrustedManualDataException or OperationCanceledException) { if (_lifetimeCancellation.IsCancellationRequested) { return; } try { var persisted = await store.ReadAsync(audience, CancellationToken.None); if (persisted.SequenceEqual(rows)) { if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) { QuarantineManualOperatorWrites( "수동 순매도 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); return; } PostManualNetSellWriteResult(requestId, audience, recovered: true); return; } QuarantineManualOperatorWrites( "수동 순매도 저장 중 파일 내용이 달라져 결과를 확정할 수 없습니다. 파일을 직접 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); PostManualOperatorQuarantine(requestId, "save-net-sell"); } catch { QuarantineManualOperatorWrites( "수동 순매도 저장 결과를 확인할 수 없습니다. 파일을 직접 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); PostManualOperatorQuarantine(requestId, "save-net-sell"); } } finally { Interlocked.Exchange(ref _manualOperatorWriteInFlight, 0); ExitManualMutationGates(); } } private async Task HandleViManualListReadAsync(JsonElement payload) { if (payload.ValueKind != JsonValueKind.Object || !HasOnlyProperties(payload, "requestId") || !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId)) { PostManualOperatorError( SafeManualOperatorRequestId(payload), "read-vi", "INVALID_REQUEST", "올바르지 않은 VI 수동 목록 조회 요청입니다.", retryable: false, outcomeUnknown: false); return; } var store = _viManualListStore; if (store is null) { PostManualOperatorUnavailable(requestId, "read-vi"); return; } var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration); await _manualOperatorDataGate.WaitAsync(_lifetimeCancellation.Token); try { var snapshot = await store.ReadSnapshotAsync(_lifetimeCancellation.Token); if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) { return; } PostMessage("vi-manual-list-data", new { requestId, itemCount = snapshot.Items.Count, pageCount = snapshot.Items.Count == 0 ? 0 : (snapshot.Items.Count + 4) / 5, items = snapshot.Items, version = snapshot.RowVersion }); } catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) { // Window shutdown owns cancellation. } catch (ViTrustedManualDataException) { PostManualOperatorError( requestId, "read-vi", "DATA_INVALID", "VI 수동 목록 파일 형식이 올바르지 않습니다.", retryable: false, outcomeUnknown: false); } finally { _manualOperatorDataGate.Release(); } } private async Task HandleViManualListWriteAsync(JsonElement payload) { if (!TryParseViWrite( payload, out var requestId, out var expectedVersion, out var items)) { PostManualOperatorError( requestId, "save-vi", "INVALID_REQUEST", $"VI 수동 목록은 최대 {ViTrustedManualFileStore.MaximumItemCount}개의 검증된 종목이어야 합니다.", retryable: false, outcomeUnknown: false); return; } var store = _viManualListStore; if (store is null) { PostManualOperatorUnavailable(requestId, "save-vi"); return; } if (IsManualOperatorWriteQuarantined()) { PostManualOperatorQuarantine(requestId, "save-vi"); return; } if (!await TryEnterManualMutationGatesAsync(requestId, "save-vi")) { return; } var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration); if (!CanStartOperatorMutation(IsManualOperatorWriteQuarantined())) { ExitManualMutationGates(); PostManualOperatorError( requestId, "save-vi", "PLAYOUT_ACTIVE", "Operator data cannot be changed unless playout is verifiably idle.", retryable: true, outcomeUnknown: false); return; } var mutationStarted = false; try { var currentSnapshot = await store.ReadSnapshotAsync(_lifetimeCancellation.Token); if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) { return; } if (!string.Equals( currentSnapshot.RowVersion, expectedVersion, StringComparison.Ordinal)) { PostManualOperatorError( requestId, "save-vi", "STALE_DATA", "The VI manual list changed after it was read. Reload it before saving.", retryable: false, outcomeUnknown: false); return; } Interlocked.Exchange(ref _manualOperatorWriteInFlight, 1); mutationStarted = true; var result = await store.WriteAsync(items, _lifetimeCancellation.Token); if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) { QuarantineManualOperatorWrites( "VI 수동 목록 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); return; } PostViWriteResult(requestId, result, recovered: false); } catch (Exception exception) when (exception is ViTrustedManualDataException or OperationCanceledException) { if (_lifetimeCancellation.IsCancellationRequested) { return; } if (!mutationStarted) { PostManualOperatorError( requestId, "save-vi", "DATA_INVALID", "The current VI manual list could not be verified before saving.", retryable: false, outcomeUnknown: false); return; } try { var persisted = await store.ReadSnapshotAsync(CancellationToken.None); if (persisted.Items.SequenceEqual(items)) { if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) { QuarantineManualOperatorWrites( "VI 수동 목록 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); return; } PostViWriteResult( requestId, new ViTrustedManualWriteResult(persisted, Persisted: items.Count != 0), recovered: true); return; } QuarantineManualOperatorWrites( "VI 수동 목록 저장 중 파일 내용이 달라져 결과를 확정할 수 없습니다. 파일을 직접 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); PostManualOperatorQuarantine(requestId, "save-vi"); } catch { QuarantineManualOperatorWrites( "VI 수동 목록 저장 결과를 확인할 수 없습니다. 파일을 직접 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); PostManualOperatorQuarantine(requestId, "save-vi"); } } finally { Interlocked.Exchange(ref _manualOperatorWriteInFlight, 0); ExitManualMutationGates(); } } private async Task TryEnterManualMutationGatesAsync( string requestId, string operation) { if (!await _playoutCommandGate.WaitAsync(0)) { PostManualOperatorError( requestId, operation, "PLAYOUT_BUSY", "송출 명령을 처리하는 동안 수동 데이터를 저장할 수 없습니다.", retryable: true, outcomeUnknown: false); return false; } if (!await _manualOperatorDataGate.WaitAsync(0)) { _playoutCommandGate.Release(); PostManualOperatorError( requestId, operation, "DATA_BUSY", "다른 수동 데이터 작업이 진행 중입니다.", retryable: true, outcomeUnknown: false); return false; } if (!CanStartOperatorMutation(IsManualOperatorWriteQuarantined())) { ExitManualMutationGates(); PostManualOperatorError( requestId, operation, "PLAYOUT_ACTIVE", "PREPARE 또는 송출 중에는 수동 데이터를 변경할 수 없습니다. TAKE OUT 후 저장하세요.", retryable: true, outcomeUnknown: false); return false; } return true; } private void ExitManualMutationGates() { _manualOperatorDataGate.Release(); _playoutCommandGate.Release(); } private static bool TryParseAudienceRequest( JsonElement payload, out string requestId, out S5025ManualAudience audience) { requestId = SafeManualOperatorRequestId(payload); audience = default; return payload.ValueKind == JsonValueKind.Object && HasOnlyProperties(payload, "requestId", "audience") && TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) && TryParseAudience(GetString(payload, "audience"), out audience); } private static string SafeManualOperatorRequestId(JsonElement payload) => payload.ValueKind == JsonValueKind.Object && TryGetSafeToken( payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId) ? requestId : string.Empty; private static bool TryParseManualNetSellWrite( JsonElement payload, out string requestId, out S5025ManualAudience audience, out IReadOnlyList rows) { requestId = SafeManualOperatorRequestId(payload); audience = default; rows = Array.Empty(); if (payload.ValueKind != JsonValueKind.Object || !HasOnlyProperties(payload, "requestId", "audience", "rows") || !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || !TryParseAudience(GetString(payload, "audience"), out audience) || !payload.TryGetProperty("rows", out var rowsValue) || rowsValue.ValueKind != JsonValueKind.Array || rowsValue.GetArrayLength() != 5) { return false; } var parsed = new List(5); foreach (var row in rowsValue.EnumerateArray()) { if (row.ValueKind != JsonValueKind.Object || !HasOnlyProperties( row, "leftName", "leftAmount", "rightName", "rightAmount") || !TryGetManualValue(row, "leftName", out var leftName) || !TryGetManualValue(row, "leftAmount", out var leftAmount) || !TryGetManualValue(row, "rightName", out var rightName) || !TryGetManualValue(row, "rightAmount", out var rightAmount)) { return false; } parsed.Add(new S5025TrustedManualRow( leftName, leftAmount, rightName, rightAmount)); } rows = parsed; return true; } private static bool TryParseViWrite( JsonElement payload, out string requestId, out string expectedVersion, out IReadOnlyList items) { requestId = SafeManualOperatorRequestId(payload); expectedVersion = string.Empty; items = Array.Empty(); if (payload.ValueKind != JsonValueKind.Object || !HasOnlyProperties(payload, "requestId", "expectedVersion", "items") || !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || !TryGetViExpectedVersion(payload, out expectedVersion) || !payload.TryGetProperty("items", out var itemsValue) || itemsValue.ValueKind != JsonValueKind.Array || itemsValue.GetArrayLength() > ViTrustedManualFileStore.MaximumItemCount) { return false; } var parsed = new List(itemsValue.GetArrayLength()); foreach (var item in itemsValue.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Object || !HasOnlyProperties(item, "code", "name") || !TryGetManualValue(item, "code", out var code, allowEmpty: false, maximumLength: 64) || !TryGetManualValue(item, "name", out var name, allowEmpty: false, maximumLength: 200) || !string.Equals(code, code.Trim(), StringComparison.Ordinal) || !string.Equals(name, name.Trim(), StringComparison.Ordinal) || name.Contains(',')) { return false; } parsed.Add(new ViTrustedManualItem(code, name)); } items = parsed; return true; } private static bool TryGetViExpectedVersion( JsonElement payload, out string expectedVersion) { expectedVersion = string.Empty; if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty("expectedVersion", out var value) || value.ValueKind != JsonValueKind.String) { return false; } expectedVersion = value.GetString() ?? string.Empty; return TrustedViManualReference.IsRowVersion(expectedVersion); } private static bool TryGetManualValue( JsonElement payload, string propertyName, out string value, bool allowEmpty = true, int maximumLength = MaximumManualValueLength) { value = GetString(payload, propertyName) ?? string.Empty; return payload.TryGetProperty(propertyName, out var element) && element.ValueKind == JsonValueKind.String && (allowEmpty || value.Length > 0) && value.Length <= maximumLength && !value.Contains('^') && !value.Any(char.IsControl); } private static bool TryParseAudience( string? value, out S5025ManualAudience audience) { audience = value switch { "INDIVIDUAL" => S5025ManualAudience.Individual, "FOREIGN" => S5025ManualAudience.Foreign, "INSTITUTION" => S5025ManualAudience.Institution, _ => default }; return value is "INDIVIDUAL" or "FOREIGN" or "INSTITUTION"; } private static string ToWireValue(S5025ManualAudience audience) => audience switch { S5025ManualAudience.Individual => "INDIVIDUAL", S5025ManualAudience.Foreign => "FOREIGN", S5025ManualAudience.Institution => "INSTITUTION", _ => throw new ArgumentOutOfRangeException(nameof(audience)) }; private void PostManualNetSellWriteResult( string requestId, S5025ManualAudience audience, bool recovered) => PostMessage("manual-net-sell-save-result", new { requestId, audience = ToWireValue(audience), saved = true, recovered }); private void PostViWriteResult( string requestId, ViTrustedManualWriteResult result, bool recovered) => PostMessage("vi-manual-list-save-result", new { requestId, saved = true, recovered, persisted = result.Persisted, itemCount = result.Snapshot.Items.Count, pageCount = result.Snapshot.Items.Count == 0 ? 0 : (result.Snapshot.Items.Count + 4) / 5, version = result.Snapshot.RowVersion }); private void PostManualOperatorUnavailable(string requestId, string operation) => PostManualOperatorError( requestId, operation, _manualOperatorDataInitializationCode ?? "DATA_UNAVAILABLE", _manualOperatorDataInitializationError ?? "수동 송출 데이터 저장소를 사용할 수 없습니다.", retryable: false, outcomeUnknown: false); private void PostManualOperatorQuarantine(string requestId, string operation) => PostManualOperatorError( requestId, operation, "OUTCOME_UNKNOWN", _manualOperatorWriteQuarantineMessage ?? "이전 저장 결과가 불명확하여 앱을 다시 시작하기 전까지 저장할 수 없습니다.", retryable: false, outcomeUnknown: true); private void PostManualOperatorError( string requestId, string operation, string code, string message, bool retryable, bool outcomeUnknown) => PostMessage("manual-operator-data-error", new { requestId, operation, code, message, retryable, outcomeUnknown }); private bool IsManualOperatorWriteQuarantined() => Volatile.Read(ref _manualOperatorWriteQuarantined) != 0 || IsOperatorMutationQuarantined(); private void QuarantineManualOperatorWrites(string message) { LatchOperatorMutationQuarantine(message); _manualOperatorWriteQuarantineMessage = message; Interlocked.Exchange(ref _manualOperatorWriteQuarantined, 1); } private void ShutdownManualOperatorDataRuntime() { InvalidateManualOperatorBrowserRequests(); var netSellStore = Interlocked.Exchange(ref _manualNetSellStore, null); var viStore = Interlocked.Exchange(ref _viManualListStore, null); var importer = Interlocked.Exchange(ref _legacyManualOperatorDataImporter, null); if (netSellStore is null && viStore is null && importer is null) { return; } _ = DisposeManualOperatorStoresWhenIdleAsync(netSellStore, viStore, importer); } private async Task DisposeManualOperatorStoresWhenIdleAsync( S5025TrustedManualFileStore? netSellStore, ViTrustedManualFileStore? viStore, LegacyManualOperatorDataImporter? importer) { await _manualOperatorDataGate.WaitAsync().ConfigureAwait(false); try { importer?.Dispose(); viStore?.Dispose(); netSellStore?.Dispose(); } finally { _manualOperatorDataGate.Release(); } } private void InvalidateManualOperatorBrowserRequests() { Interlocked.Increment(ref _manualOperatorBrowserGeneration); if (Volatile.Read(ref _manualOperatorWriteInFlight) != 0) { QuarantineManualOperatorWrites( "수동 데이터 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); } } }