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 string? _manualOperatorDataInitializationError; 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)) { _manualOperatorDataInitializationError = "외부 수동 데이터 경로는 TOCTOU 안전성을 보장할 수 없어 비활성화되었습니다. 앱 전용 로컬 운영 데이터 폴더를 사용하세요."; return; } var directory = TrustedManualDirectory.PreparePrivateWritableDirectory(Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MBN_STOCK_WEBVIEW", "OperatorData")); _manualNetSellStore = new S5025TrustedManualFileStore(directory); _viManualListStore = new ViTrustedManualFileStore(directory); } catch { _viManualListStore?.Dispose(); _manualNetSellStore?.Dispose(); _viManualListStore = null; _manualNetSellStore = null; _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, writeQuarantined = IsManualOperatorWriteQuarantined(), message = IsManualOperatorWriteQuarantined() ? GetOperatorMutationQuarantineMessage( _manualOperatorWriteQuarantineMessage ?? ManualOperatorCorrelationQuarantineMessage) : _manualOperatorDataInitializationError }); } 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, "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); if (netSellStore is null && viStore is null) { return; } _ = DisposeManualOperatorStoresWhenIdleAsync(netSellStore, viStore); } private async Task DisposeManualOperatorStoresWhenIdleAsync( S5025TrustedManualFileStore? netSellStore, ViTrustedManualFileStore? viStore) { await _manualOperatorDataGate.WaitAsync().ConfigureAwait(false); try { viStore?.Dispose(); netSellStore?.Dispose(); } finally { _manualOperatorDataGate.Release(); } } private void InvalidateManualOperatorBrowserRequests() { Interlocked.Increment(ref _manualOperatorBrowserGeneration); if (Volatile.Read(ref _manualOperatorWriteInFlight) != 0) { QuarantineManualOperatorWrites( "수동 데이터 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); } } }