using System.Diagnostics; using System.Reflection; using System.Runtime.InteropServices; using System.Text.Json; using MBN_STOCK_WEBVIEW.Core.Playout; using MBN_STOCK_WEBVIEW.Infrastructure; using Microsoft.UI.Windowing; using Microsoft.Web.WebView2.Core; using MMoneyCoderSharp.Data; using Windows.Graphics; using Windows.System; using WinRT.Interop; namespace MBN_STOCK_WEBVIEW; public sealed partial class MainWindow : Window { private const string AppHost = "app.mbn.local"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly CancellationTokenSource _lifetimeCancellation = new(); private readonly SemaphoreSlim _databaseActivityGate = new(1, 1); private DatabaseRuntime? _databaseRuntime; private IMarketDataService? _marketDataService; private IStockSearchService? _stockSearchService; private IWorldStockSearchService? _worldStockSearchService; private IOverseasIndustryIndexSearchService? _overseasIndustryIndexSearchService; private IThemeSelectionService? _themeSelectionService; private IIndustrySelectionService? _industrySelectionService; private ITradingHaltSelectionService? _tradingHaltSelectionService; private IExpertSelectionService? _expertSelectionService; private INamedPlaylistPersistenceService? _namedPlaylistPersistenceService; private CancellationTokenSource? _marketDataCancellation; private CancellationTokenSource? _stockSearchCancellation; private CancellationTokenSource? _worldStockSearchCancellation; private CancellationTokenSource? _overseasIndustrySearchCancellation; private CancellationTokenSource? _themeSelectionCancellation; private CancellationTokenSource? _industrySelectionCancellation; private CancellationTokenSource? _tradingHaltSelectionCancellation; private CancellationTokenSource? _expertSelectionCancellation; private CancellationTokenSource? _namedPlaylistReadCancellation; private CancellationTokenSource? _namedPlaylistWriteCancellation; private CancellationTokenSource? _playlistPagePlanCancellation; private CancellationTokenSource? _databaseHealthCancellation; private readonly SemaphoreSlim _namedPlaylistWriteGate = new(1, 1); private int _namedPlaylistWriteInFlight; private int _namedPlaylistWriteQuarantined; private int _namedPlaylistBrowserGeneration; private int _playlistPagePlanBrowserGeneration; private string? _namedPlaylistWriteQuarantineMessage; private long _databaseStatusSequence; private string? _databaseInitializationError; private bool _webViewReady; public MainWindow() { InitializeComponent(); LogNativeOperatorStartup(); EnsureCloseConfirmationAttached(); InitializeManualOperatorDataRuntime(); InitializeDatabaseRuntime(); InitializePlayoutRuntime(); Root.Loaded += OnRootLoaded; Closed += OnClosed; } private void InitializeDatabaseRuntime() { try { _databaseRuntime = DatabaseRuntime.CreateDefault(); DataQueryExecutor.Configure(_databaseRuntime.Executor); _marketDataService = new LegacyMarketDataService(_databaseRuntime.Executor); _stockSearchService = new LegacyStockSearchService(_databaseRuntime.Executor); _worldStockSearchService = new LegacyWorldStockSearchService(_databaseRuntime.Executor); _overseasIndustryIndexSearchService = new LegacyOverseasIndustryIndexSearchService(_databaseRuntime.Executor); _themeSelectionService = new LegacyThemeSelectionService(_databaseRuntime.Executor); _industrySelectionService = new LegacyIndustrySelectionService(_databaseRuntime.Executor); _tradingHaltSelectionService = new LegacyTradingHaltSelectionService( _databaseRuntime.Executor); _expertSelectionService = new LegacyExpertSelectionService(_databaseRuntime.Executor); _namedPlaylistPersistenceService = new LegacyNamedPlaylistPersistenceService( _databaseRuntime.Executor, new OracleNamedPlaylistMutationExecutor( _databaseRuntime.ConnectionFactory, _databaseRuntime.Options.Resilience)); InitializeOperatorCatalogRuntime(); InitializeManualFinancialRuntime(); } catch (DatabaseOperationException exception) { _databaseInitializationError = exception.Message; DataQueryExecutor.Reset(); } catch { _databaseInitializationError = "데이터베이스 설정을 초기화하지 못했습니다. 로컬 설정 파일을 확인하세요."; DataQueryExecutor.Reset(); } } private async void OnRootLoaded(object sender, RoutedEventArgs e) { Root.Loaded -= OnRootLoaded; EnsureCloseConfirmationAttached(); ResizeWindow(); await InitializeWebViewAsync(); if (_webViewReady) { _ = MonitorDatabaseHealthAsync(_lifetimeCancellation.Token); _ = ConnectPlayoutAsync(_lifetimeCancellation.Token); } } private void ResizeWindow() { try { var windowHandle = WindowNative.GetWindowHandle(this); var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle); AppWindow.GetFromWindowId(windowId).Resize(new SizeInt32(1600, 960)); } catch { // The system-selected size is still usable if sizing is unavailable. } } private async Task InitializeWebViewAsync() { try { var webRoot = Path.Combine(AppContext.BaseDirectory, "Web"); var startPage = Path.Combine(webRoot, "index.html"); if (!File.Exists(startPage)) { throw new FileNotFoundException("The packaged Web UI was not found.", startPage); } await Browser.EnsureCoreWebView2Async(); Browser.CoreWebView2.SetVirtualHostNameToFolderMapping( AppHost, webRoot, CoreWebView2HostResourceAccessKind.DenyCors); Browser.CoreWebView2.Settings.AreDevToolsEnabled = Debugger.IsAttached; Browser.CoreWebView2.Settings.AreDefaultContextMenusEnabled = Debugger.IsAttached; Browser.CoreWebView2.Settings.IsStatusBarEnabled = false; Browser.CoreWebView2.NavigationStarting += OnNavigationStarting; Browser.CoreWebView2.NavigationCompleted += OnNavigationCompleted; Browser.CoreWebView2.HistoryChanged += OnHistoryChanged; Browser.CoreWebView2.NewWindowRequested += OnNewWindowRequested; Browser.CoreWebView2.WebMessageReceived += OnWebMessageReceived; Browser.CoreWebView2.ProcessFailed += OnProcessFailed; _webViewReady = true; Browser.Source = new Uri($"https://{AppHost}/index.html"); } catch (Exception exception) { ShowError("WebView2 초기화에 실패했습니다.", exception.Message); } } private void OnNavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs args) { if (!Uri.TryCreate(args.Uri, UriKind.Absolute, out var uri)) { args.Cancel = true; return; } if (PlayoutBridgeProtocol.IsTrustedSource(uri.AbsoluteUri, AppHost) || uri.Scheme == "about") { QuarantineIfBrowserCorrelationCanBeLost(); LoadingOverlay.Visibility = Visibility.Visible; return; } args.Cancel = true; _ = OpenExternalUriAsync(uri); } private void OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs args) { LoadingOverlay.Visibility = Visibility.Collapsed; if (!args.IsSuccess) { ShowError("운영 화면을 불러오지 못했습니다.", args.WebErrorStatus.ToString()); } } private void OnHistoryChanged(object? sender, object args) { BackButton.IsEnabled = Browser.CanGoBack; ForwardButton.IsEnabled = Browser.CanGoForward; } private void OnNewWindowRequested(object? sender, CoreWebView2NewWindowRequestedEventArgs args) { args.Handled = true; if (Uri.TryCreate(args.Uri, UriKind.Absolute, out var uri)) { _ = OpenExternalUriAsync(uri); } } private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs args) { if (!PlayoutBridgeProtocol.IsTrustedSource(args.Source, AppHost)) { return; } try { var request = JsonSerializer.Deserialize(args.WebMessageAsJson, JsonOptions); if (request is not null && TryHandleOperatorCatalogRequest(request.Type, request.Payload)) { return; } if (request is not null && TryHandleManualFinancialRequest(request.Type, request.Payload)) { return; } if (request is not null && TryHandleLegacyComparisonImportRequest(request.Type, request.Payload)) { return; } switch (request?.Type) { case "ready": case "request-app-info": PostAppInfo(); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); PostPlayoutStatus(); break; case "request-database-status": _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); break; case "request-playout-status": HandlePlayoutStatusRequest(request.Payload); break; case "playout-command": _ = HandlePlayoutCommandAsync(request.Payload); break; case "playout-timeout-quarantine": _ = HandlePlayoutTimeoutQuarantineAsync(request.Payload); break; case "request-playlist-page-plans" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandlePlaylistPagePlansRequestAsync(request.Payload); break; case "request-playlist-page-plans": PostPlaylistPagePlansError( string.Empty, "INVALID_REQUEST", "The playlist page-plan request payload is invalid.", retryable: false); break; case "request-background-status": HandleBackgroundStatusRequest(request.Payload); break; case "choose-background": _ = HandleChooseBackgroundAsync(request.Payload); break; case "toggle-background": _ = HandleToggleBackgroundAsync(request.Payload); break; case "request-market-data" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleMarketDataRequestAsync(request.Payload); break; case "request-market-data": PostMessage("market-data-error", new { requestId = string.Empty, view = string.Empty, message = "The market-data request payload is invalid." }); break; case "search-stocks" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleStockSearchRequestAsync(request.Payload); break; case "search-stocks": PostStockSearchError( string.Empty, string.Empty, "The stock-search request payload is invalid."); break; case "search-world-stocks" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleWorldStockSearchRequestAsync(request.Payload); break; case "search-world-stocks": PostWorldStockSearchError( string.Empty, string.Empty, "올바르지 않은 해외종목 검색 요청입니다."); break; case "search-overseas-industries" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleOverseasIndustrySearchRequestAsync(request.Payload); break; case "search-overseas-industries": PostOverseasIndustrySearchError( string.Empty, string.Empty, "올바르지 않은 미국 해외업종 검색 요청입니다."); break; case "search-themes" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleThemeSearchRequestAsync(request.Payload); break; case "search-themes": PostThemeSearchError( string.Empty, string.Empty, string.Empty, "The theme-search request payload is invalid."); break; case "request-theme-preview" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleThemePreviewRequestAsync(request.Payload); break; case "request-theme-preview": PostThemePreviewError( string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, "The theme-preview request payload is invalid."); break; case "search-experts" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleExpertSearchRequestAsync(request.Payload); break; case "search-experts": PostExpertSearchError( string.Empty, string.Empty, "올바르지 않은 전문가 검색 요청입니다."); break; case "request-expert-preview" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleExpertPreviewRequestAsync(request.Payload); break; case "request-expert-preview": PostExpertPreviewError( string.Empty, string.Empty, string.Empty, "올바르지 않은 전문가 미리보기 요청입니다."); break; case "request-industries" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleIndustrySelectionRequestAsync(request.Payload); break; case "request-industries": PostIndustrySelectionError( string.Empty, string.Empty, "The industry request payload is invalid."); break; case "search-trading-halts" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleTradingHaltSelectionRequestAsync(request.Payload); break; case "search-trading-halts": PostTradingHaltSelectionError( string.Empty, string.Empty, "올바르지 않은 거래정지 종목 검색 요청입니다."); break; case "request-manual-operator-data-status": HandleManualOperatorStatusRequest(request.Payload); break; case "import-legacy-manual-operator-data": _ = HandleLegacyManualOperatorImportAsync(request.Payload); break; case "request-manual-net-sell-data": _ = HandleManualNetSellReadAsync(request.Payload); break; case "save-manual-net-sell-data": _ = HandleManualNetSellWriteAsync(request.Payload); break; case "request-vi-manual-list": _ = HandleViManualListReadAsync(request.Payload); break; case "save-vi-manual-list": _ = HandleViManualListWriteAsync(request.Payload); break; case "request-named-playlist-list" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleNamedPlaylistListRequestAsync(request.Payload); break; case "request-named-playlist-load" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleNamedPlaylistLoadRequestAsync(request.Payload); break; case "create-named-playlist" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleNamedPlaylistCreateRequestAsync(request.Payload); break; case "replace-named-playlist" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleNamedPlaylistReplaceRequestAsync(request.Payload); break; case "delete-named-playlist" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleNamedPlaylistDeleteRequestAsync(request.Payload); break; case "request-named-playlist-list": case "request-named-playlist-load": case "create-named-playlist": case "replace-named-playlist": case "delete-named-playlist": PostNamedPlaylistError( string.Empty, "unknown", "INVALID_REQUEST", "The named-playlist request payload is invalid.", retryable: false, outcomeUnknown: false); break; } } catch (JsonException exception) { PostMessage("bridge-error", new { message = exception.Message }); } catch { PostMessage("bridge-error", new { message = "요청 메시지를 처리하지 못했습니다." }); } } private async Task HandleMarketDataRequestAsync(JsonElement payload) { var requestId = GetString(payload, "requestId"); var viewName = GetString(payload, "view"); if (string.IsNullOrWhiteSpace(requestId) || requestId.Length > 128 || !TryParseMarketDataView(viewName, out var view)) { PostMessage("market-data-error", new { requestId = requestId ?? string.Empty, view = viewName ?? string.Empty, message = "지원하지 않는 시장 데이터 요청입니다." }); return; } var maximumRows = 250; if (payload.TryGetProperty("maximumRowsPerTable", out var rowLimit) && rowLimit.ValueKind == JsonValueKind.Number && rowLimit.TryGetInt32(out var requestedRows)) { maximumRows = requestedRows; } if (maximumRows is < 1 or > 2_000) { PostMessage("market-data-error", new { requestId, view = viewName, message = "한 번에 조회할 수 있는 행 수는 1~2,000개입니다." }); return; } if (_marketDataService is null) { PostMessage("market-data-error", new { requestId, view = viewName, message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다." }); return; } var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCancellation.Token); var requestToken = requestCancellation.Token; var previousCancellation = Interlocked.Exchange(ref _marketDataCancellation, requestCancellation); previousCancellation?.Cancel(); var databaseActivityEntered = false; try { if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); return; } await _databaseActivityGate.WaitAsync(requestToken); databaseActivityEntered = true; requestToken.ThrowIfCancellationRequested(); // Close the race where playout starts after this request is published // but before its market query begins. if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); return; } var snapshot = await _marketDataService .GetAsync(view, maximumRows, requestToken); requestToken.ThrowIfCancellationRequested(); PostMessage("market-data", new { requestId, view = ToWireValue(snapshot.View), snapshot.RetrievedAt, tables = snapshot.Tables.Select(table => new { table.Name, source = ToWireValue(table.Source), table.Columns, table.Rows, table.TotalRowCount }) }); } catch (OperationCanceledException) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityMarketDataCancellationIfCurrent( requestId, view, requestCancellation); } catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) { // Some providers surface a provider exception instead of cancellation after // a command has already taken priority. Never publish that stale failure. PostPlayoutPriorityMarketDataCancellationIfCurrent( requestId, view, requestCancellation); } catch (Exception) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityMarketDataCancellationIfCurrent( requestId, view, requestCancellation); } catch (DatabaseOperationException exception) { PostMarketDataError(requestId, view, exception.Message); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch { PostMarketDataError(requestId, view, "시장 데이터를 조회하지 못했습니다. DB 연결 상태를 확인하세요."); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } finally { if (databaseActivityEntered) { _databaseActivityGate.Release(); } Interlocked.CompareExchange( ref _marketDataCancellation, null, requestCancellation); requestCancellation.Dispose(); } } private void PostPlayoutPriorityMarketDataCancellationIfCurrent( string requestId, MarketDataView view, CancellationTokenSource requestCancellation) { if (_lifetimeCancellation.IsCancellationRequested || !ReferenceEquals( Volatile.Read(ref _marketDataCancellation), requestCancellation)) { // App shutdown or a newer navigation request superseded this response. return; } PostMarketDataError( requestId, view, "송출 명령과 자동 갱신을 우선 처리하기 위해 조회를 취소했습니다. 다시 조회하세요."); } private void PostMarketDataError(string requestId, MarketDataView view, string message) { PostMessage("market-data-error", new { requestId, view = ToWireValue(view), message }); } private async Task HandleStockSearchRequestAsync(JsonElement payload) { var requestId = GetString(payload, "requestId") ?? string.Empty; var query = GetString(payload, "query") ?? string.Empty; if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") || !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId)) { PostStockSearchError(requestId, query, "올바르지 않은 종목 검색 요청입니다."); return; } var maximumResults = LegacyStockSearchService.DefaultMaximumResults; if (payload.TryGetProperty("maximumResults", out var requestedLimit)) { if (requestedLimit.ValueKind != JsonValueKind.Number || !requestedLimit.TryGetInt32(out maximumResults)) { PostStockSearchError(requestId, query, "검색 결과 제한값이 올바르지 않습니다."); return; } } if (_stockSearchService is null) { PostStockSearchError( requestId, query, _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); return; } var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( _lifetimeCancellation.Token); var requestToken = requestCancellation.Token; var previousCancellation = Interlocked.Exchange( ref _stockSearchCancellation, requestCancellation); previousCancellation?.Cancel(); var databaseActivityEntered = false; try { if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); PostPlayoutPriorityStockSearchCancellationIfCurrent( requestId, query, requestCancellation); return; } await _databaseActivityGate.WaitAsync(requestToken); databaseActivityEntered = true; requestToken.ThrowIfCancellationRequested(); // A playout command always wins the race for the shared DB executor. if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); PostPlayoutPriorityStockSearchCancellationIfCurrent( requestId, query, requestCancellation); return; } var result = await _stockSearchService.SearchAsync( query, maximumResults, requestToken); requestToken.ThrowIfCancellationRequested(); PostMessage("stock-search-results", new { requestId, query = result.Query, result.RetrievedAt, totalRowCount = result.Items.Count, truncated = result.IsTruncated, results = result.Items.Select(item => new { market = ToWireValue(item.Market), source = ToWireValue(item.Source), name = item.Name, code = item.Code }) }); } catch (OperationCanceledException) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityStockSearchCancellationIfCurrent( requestId, query, requestCancellation); } catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) { // A canceled provider may surface its native exception. Do not publish // stale provider details after playout or a newer search took priority. PostPlayoutPriorityStockSearchCancellationIfCurrent( requestId, query, requestCancellation); } catch (Exception) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityStockSearchCancellationIfCurrent( requestId, query, requestCancellation); } catch (ArgumentException) { PostStockSearchError( requestId, query, $"검색어는 공백이 아닌 {LegacyStockSearchService.MaximumQueryLength}자 이하여야 하며 결과 제한은 1~{LegacyStockSearchService.MaximumResults}개입니다."); } catch (StockSearchDataException) { PostStockSearchError(requestId, query, "종목 검색 결과 형식이 올바르지 않습니다."); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch (DatabaseOperationException exception) { PostStockSearchError(requestId, query, exception.Message); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch { PostStockSearchError( requestId, query, "종목을 검색하지 못했습니다. DB 연결 상태를 확인하세요."); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } finally { if (databaseActivityEntered) { _databaseActivityGate.Release(); } Interlocked.CompareExchange( ref _stockSearchCancellation, null, requestCancellation); requestCancellation.Dispose(); } } private void PostPlayoutPriorityStockSearchCancellationIfCurrent( string requestId, string query, CancellationTokenSource requestCancellation) { if (_lifetimeCancellation.IsCancellationRequested || !ReferenceEquals( Volatile.Read(ref _stockSearchCancellation), requestCancellation)) { // Shutdown or a newer stock search superseded this response. return; } PostStockSearchError( requestId, query, "송출 명령과 자동 갱신을 우선 처리하기 위해 검색을 취소했습니다. 다시 검색하세요."); } private void PostStockSearchError(string requestId, string query, string message) { PostMessage("stock-search-error", new { requestId, query, message }); } private async Task HandleWorldStockSearchRequestAsync(JsonElement payload) { var requestId = GetString(payload, "requestId") ?? string.Empty; var query = GetString(payload, "query") ?? string.Empty; if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") || !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || !payload.TryGetProperty("query", out var queryElement) || queryElement.ValueKind != JsonValueKind.String) { PostWorldStockSearchError( requestId, query, "올바르지 않은 해외종목 검색 요청입니다."); return; } var maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults; if (payload.TryGetProperty("maximumResults", out var requestedLimit)) { if (requestedLimit.ValueKind != JsonValueKind.Number || !requestedLimit.TryGetInt32(out maximumResults)) { PostWorldStockSearchError( requestId, query, "검색 결과 제한값이 올바르지 않습니다."); return; } } if (_worldStockSearchService is null) { PostWorldStockSearchError( requestId, query, _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); return; } var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( _lifetimeCancellation.Token); var requestToken = requestCancellation.Token; var previousCancellation = Interlocked.Exchange( ref _worldStockSearchCancellation, requestCancellation); previousCancellation?.Cancel(); var databaseActivityEntered = false; try { if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); PostPlayoutPriorityWorldStockSearchCancellationIfCurrent( requestId, query, requestCancellation); return; } await _databaseActivityGate.WaitAsync(requestToken); databaseActivityEntered = true; requestToken.ThrowIfCancellationRequested(); // Playout commands always take priority over operator lookup reads. if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); PostPlayoutPriorityWorldStockSearchCancellationIfCurrent( requestId, query, requestCancellation); return; } var result = await _worldStockSearchService.SearchAsync( query, maximumResults, requestToken); requestToken.ThrowIfCancellationRequested(); PostMessage("world-stock-search-results", new { requestId, query = result.Query, result.RetrievedAt, totalRowCount = result.Items.Count, truncated = result.IsTruncated, results = result.Items.Select(item => new { inputName = item.InputName, symbol = item.Symbol, nationCode = item.NationCode, fdtc = "1" }) }); } catch (OperationCanceledException) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityWorldStockSearchCancellationIfCurrent( requestId, query, requestCancellation); } catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) { // Providers can surface a native error after cancellation. Suppress // stale details when playout or a newer lookup owns the DB slot. PostPlayoutPriorityWorldStockSearchCancellationIfCurrent( requestId, query, requestCancellation); } catch (Exception) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityWorldStockSearchCancellationIfCurrent( requestId, query, requestCancellation); } catch (ArgumentException) { PostWorldStockSearchError( requestId, query, $"검색어는 공백이 아닌 {LegacyWorldStockSearchService.MaximumQueryLength}자 이하이고 결과 제한은 1~{LegacyWorldStockSearchService.MaximumResults}개여야 합니다."); } catch (WorldStockSearchDataException) { PostWorldStockSearchError( requestId, query, "해외종목 검색 결과의 형식 또는 식별자가 올바르지 않습니다."); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch (DatabaseOperationException exception) { PostWorldStockSearchError(requestId, query, exception.Message); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch { PostWorldStockSearchError( requestId, query, "해외종목을 검색하지 못했습니다. DB 연결 상태를 확인하세요."); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } finally { if (databaseActivityEntered) { _databaseActivityGate.Release(); } Interlocked.CompareExchange( ref _worldStockSearchCancellation, null, requestCancellation); requestCancellation.Dispose(); } } private void PostPlayoutPriorityWorldStockSearchCancellationIfCurrent( string requestId, string query, CancellationTokenSource requestCancellation) { if (_lifetimeCancellation.IsCancellationRequested || !ReferenceEquals( Volatile.Read(ref _worldStockSearchCancellation), requestCancellation)) { // Shutdown or a newer world-stock search superseded this response. return; } PostWorldStockSearchError( requestId, query, "송출 명령과 자동 갱신을 우선 처리하기 위해 해외종목 검색을 취소했습니다. 다시 검색하세요."); } private void PostWorldStockSearchError(string requestId, string query, string message) { PostMessage("world-stock-search-error", new { requestId, query, message }); } private async Task HandleOverseasIndustrySearchRequestAsync(JsonElement payload) { var requestId = GetString(payload, "requestId") ?? string.Empty; var query = GetString(payload, "query") ?? string.Empty; if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") || !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || !payload.TryGetProperty("query", out var queryElement) || queryElement.ValueKind != JsonValueKind.String) { PostOverseasIndustrySearchError( requestId, query, "올바르지 않은 미국 해외업종 검색 요청입니다."); return; } var maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults; if (payload.TryGetProperty("maximumResults", out var requestedLimit) && (requestedLimit.ValueKind != JsonValueKind.Number || !requestedLimit.TryGetInt32(out maximumResults))) { PostOverseasIndustrySearchError( requestId, query, "해외업종 검색 결과 제한값이 올바르지 않습니다."); return; } var service = _overseasIndustryIndexSearchService; if (service is null) { PostOverseasIndustrySearchError( requestId, query, _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); return; } var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( _lifetimeCancellation.Token); var requestToken = requestCancellation.Token; var previousCancellation = Interlocked.Exchange( ref _overseasIndustrySearchCancellation, requestCancellation); previousCancellation?.Cancel(); var databaseActivityEntered = false; try { if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); PostPlayoutPriorityOverseasIndustryCancellationIfCurrent( requestId, query, requestCancellation); return; } await _databaseActivityGate.WaitAsync(requestToken); databaseActivityEntered = true; requestToken.ThrowIfCancellationRequested(); if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); PostPlayoutPriorityOverseasIndustryCancellationIfCurrent( requestId, query, requestCancellation); return; } var result = await service.SearchAsync(query, maximumResults, requestToken); requestToken.ThrowIfCancellationRequested(); PostMessage("overseas-industry-results", new { requestId, query = result.Query, result.RetrievedAt, totalRowCount = result.Items.Count, truncated = result.IsTruncated, results = result.Items.Select(item => new { koreanName = item.KoreanName, inputName = item.InputName, symbol = item.Symbol, nationCode = "US", fdtc = "0" }).ToArray() }); } catch (OperationCanceledException) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityOverseasIndustryCancellationIfCurrent( requestId, query, requestCancellation); } catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityOverseasIndustryCancellationIfCurrent( requestId, query, requestCancellation); } catch (Exception) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityOverseasIndustryCancellationIfCurrent( requestId, query, requestCancellation); } catch (ArgumentException) { PostOverseasIndustrySearchError( requestId, query, $"검색어는 {LegacyOverseasIndustryIndexSearchService.MaximumQueryLength}자 이하이고 결과 제한은 1~{LegacyOverseasIndustryIndexSearchService.MaximumResults}개여야 합니다."); } catch (OverseasIndustryIndexSearchDataException) { PostOverseasIndustrySearchError( requestId, query, "미국 해외업종 검색 결과의 스키마 또는 식별자가 올바르지 않습니다."); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch (DatabaseOperationException exception) { PostOverseasIndustrySearchError(requestId, query, exception.Message); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch { PostOverseasIndustrySearchError( requestId, query, "미국 해외업종을 검색하지 못했습니다. DB 연결 상태를 확인하세요."); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } finally { if (databaseActivityEntered) { _databaseActivityGate.Release(); } Interlocked.CompareExchange( ref _overseasIndustrySearchCancellation, null, requestCancellation); requestCancellation.Dispose(); } } private void PostPlayoutPriorityOverseasIndustryCancellationIfCurrent( string requestId, string query, CancellationTokenSource requestCancellation) { if (_lifetimeCancellation.IsCancellationRequested || !ReferenceEquals( Volatile.Read(ref _overseasIndustrySearchCancellation), requestCancellation)) { // Shutdown or a newer overseas-industry lookup owns the response. return; } PostOverseasIndustrySearchError( requestId, query, "송출 명령과 자동 갱신을 우선 처리하기 위해 미국 해외업종 검색을 취소했습니다. 다시 검색하세요."); } private void PostOverseasIndustrySearchError( string requestId, string query, string message) { PostMessage("overseas-industry-error", new { requestId, query, message }); } private async Task HandleThemeSearchRequestAsync(JsonElement payload) { var requestId = GetString(payload, "requestId") ?? string.Empty; var query = GetString(payload, "query") ?? string.Empty; var nxtSessionValue = GetString(payload, "nxtSession") ?? string.Empty; if (!HasOnlyProperties( payload, "requestId", "query", "nxtSession", "maximumResults") || !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || !payload.TryGetProperty("query", out var queryElement) || queryElement.ValueKind != JsonValueKind.String || !TryParseThemeNxtSession(nxtSessionValue, out var nxtSession)) { PostThemeSearchError( requestId, query, nxtSessionValue, "올바르지 않은 테마 검색 요청입니다."); return; } var maximumResults = LegacyThemeSelectionService.DefaultMaximumResults; if (payload.TryGetProperty("maximumResults", out var requestedLimit) && (requestedLimit.ValueKind != JsonValueKind.Number || !requestedLimit.TryGetInt32(out maximumResults))) { PostThemeSearchError( requestId, query, nxtSessionValue, "테마 검색 결과 제한값이 올바르지 않습니다."); return; } var service = _themeSelectionService; if (service is null) { PostThemeSearchError( requestId, query, nxtSessionValue, _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); return; } await ExecuteThemeSelectionRequestAsync( async requestToken => { var result = await service.SearchAsync( query, nxtSession, maximumResults, requestToken); requestToken.ThrowIfCancellationRequested(); PostMessage("theme-search-results", new { requestId, query = result.Query, nxtSession = ToWireValue(result.NxtSession), result.RetrievedAt, totalRowCount = result.Items.Count, truncated = result.IsTruncated, results = result.Items.Select(item => new { market = ToWireValue(item.Identity.Market), session = ToWireValue(item.Identity.Session), themeCode = item.Identity.ThemeCode, title = item.Identity.ThemeTitle, displayTitle = item.Identity.Market == ThemeMarket.Nxt ? $"{item.Identity.ThemeTitle}(NXT)" : item.Identity.ThemeTitle, source = ToWireValue(item.Source) }).ToArray() }); }, message => PostThemeSearchError( requestId, query, nxtSessionValue, message), $"검색어는 {LegacyThemeSelectionService.MaximumQueryLength}자 이하이고 결과 제한은 1~{LegacyThemeSelectionService.MaximumResults}개여야 합니다.", "테마 검색 결과의 스키마 또는 식별자가 올바르지 않습니다.", "테마를 검색하지 못했습니다. DB 연결 상태를 확인하세요."); } private void PostThemeSearchError( string requestId, string query, string nxtSession, string message) { PostMessage("theme-search-error", new { requestId, query, nxtSession, message }); } private async Task HandleThemePreviewRequestAsync(JsonElement payload) { var requestId = GetString(payload, "requestId") ?? string.Empty; var marketValue = GetString(payload, "market") ?? string.Empty; var sessionValue = GetString(payload, "session") ?? string.Empty; var themeCode = GetString(payload, "themeCode") ?? string.Empty; var themeTitle = GetString(payload, "themeTitle") ?? string.Empty; if (!HasOnlyProperties( payload, "requestId", "market", "session", "themeCode", "themeTitle", "maximumItems") || !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || !TryParseThemeMarket(marketValue, out var market) || !TryParseThemeSession(sessionValue, out var session) || !payload.TryGetProperty("themeCode", out var codeElement) || codeElement.ValueKind != JsonValueKind.String || !payload.TryGetProperty("themeTitle", out var titleElement) || titleElement.ValueKind != JsonValueKind.String) { PostThemePreviewError( requestId, marketValue, sessionValue, themeCode, themeTitle, "올바르지 않은 테마 미리보기 요청입니다."); return; } var maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems; if (payload.TryGetProperty("maximumItems", out var requestedLimit) && (requestedLimit.ValueKind != JsonValueKind.Number || !requestedLimit.TryGetInt32(out maximumItems))) { PostThemePreviewError( requestId, marketValue, sessionValue, themeCode, themeTitle, "테마 미리보기 종목 제한값이 올바르지 않습니다."); return; } var service = _themeSelectionService; if (service is null) { PostThemePreviewError( requestId, marketValue, sessionValue, themeCode, themeTitle, _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); return; } var identity = new ThemeSelectionIdentity(market, session, themeCode, themeTitle); await ExecuteThemeSelectionRequestAsync( async requestToken => { var result = await service.PreviewAsync( identity, maximumItems, requestToken); requestToken.ThrowIfCancellationRequested(); PostMessage("theme-preview-results", new { requestId, selection = new { market = ToWireValue(result.Identity.Market), session = ToWireValue(result.Identity.Session), themeCode = result.Identity.ThemeCode, title = result.Identity.ThemeTitle }, result.RetrievedAt, totalRowCount = result.Items.Count, truncated = result.IsTruncated, items = result.Items.Select(item => new { inputIndex = item.InputIndex, itemCode = item.ItemCode, itemName = item.ItemName }).ToArray() }); }, message => PostThemePreviewError( requestId, marketValue, sessionValue, themeCode, themeTitle, message), $"테마 시장·세션·코드·제목을 확인하고 종목 제한은 1~{LegacyThemeSelectionService.MaximumPreviewItems}개로 지정하세요.", "테마 미리보기 결과의 스키마 또는 식별자가 올바르지 않습니다.", "테마 종목을 불러오지 못했습니다. DB 연결 상태를 확인하세요."); } private void PostThemePreviewError( string requestId, string market, string session, string themeCode, string themeTitle, string message) { PostMessage("theme-preview-error", new { requestId, selection = new { market, session, themeCode, title = themeTitle }, message }); } private async Task ExecuteThemeSelectionRequestAsync( Func operation, Action postError, string validationMessage, string invalidDataMessage, string unexpectedMessage) { var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( _lifetimeCancellation.Token); var requestToken = requestCancellation.Token; var previousCancellation = Interlocked.Exchange( ref _themeSelectionCancellation, requestCancellation); previousCancellation?.Cancel(); var databaseActivityEntered = false; try { if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); PostPlayoutPriorityThemeCancellationIfCurrent( postError, requestCancellation); return; } await _databaseActivityGate.WaitAsync(requestToken); databaseActivityEntered = true; requestToken.ThrowIfCancellationRequested(); if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); PostPlayoutPriorityThemeCancellationIfCurrent( postError, requestCancellation); return; } await operation(requestToken); requestToken.ThrowIfCancellationRequested(); } catch (OperationCanceledException) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityThemeCancellationIfCurrent( postError, requestCancellation); } catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityThemeCancellationIfCurrent( postError, requestCancellation); } catch (Exception) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityThemeCancellationIfCurrent( postError, requestCancellation); } catch (ArgumentException) { postError(validationMessage); } catch (ThemeSelectionDataException) { postError(invalidDataMessage); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch (DatabaseOperationException exception) { postError(exception.Message); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch { postError(unexpectedMessage); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } finally { if (databaseActivityEntered) { _databaseActivityGate.Release(); } Interlocked.CompareExchange( ref _themeSelectionCancellation, null, requestCancellation); requestCancellation.Dispose(); } } private void PostPlayoutPriorityThemeCancellationIfCurrent( Action postError, CancellationTokenSource requestCancellation) { if (_lifetimeCancellation.IsCancellationRequested || !ReferenceEquals( Volatile.Read(ref _themeSelectionCancellation), requestCancellation)) { // Shutdown or a newer theme selector request owns the response. return; } postError( "송출 명령과 자동 갱신을 우선 처리하기 위해 테마 조회를 취소했습니다. 다시 조회하세요."); } private async Task HandleExpertSearchRequestAsync(JsonElement payload) { var requestId = GetString(payload, "requestId") ?? string.Empty; var query = GetString(payload, "query") ?? string.Empty; if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") || !TryGetSafeToken( payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || !payload.TryGetProperty("query", out var queryElement) || queryElement.ValueKind != JsonValueKind.String) { PostExpertSearchError( requestId, query, "올바르지 않은 전문가 검색 요청입니다."); return; } var maximumResults = LegacyExpertSelectionService.DefaultMaximumResults; if (payload.TryGetProperty("maximumResults", out var requestedLimit) && (requestedLimit.ValueKind != JsonValueKind.Number || !requestedLimit.TryGetInt32(out maximumResults))) { PostExpertSearchError( requestId, query, "전문가 검색 결과 제한값이 올바르지 않습니다."); return; } var service = _expertSelectionService; if (service is null) { PostExpertSearchError( requestId, query, _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); return; } await ExecuteExpertSelectionRequestAsync( async requestToken => { var result = await service.SearchAsync( query, maximumResults, requestToken); requestToken.ThrowIfCancellationRequested(); PostMessage("expert-search-results", new { requestId, query = result.Query, result.RetrievedAt, totalRowCount = result.Items.Count, truncated = result.IsTruncated, results = result.Items.Select(item => new { expertCode = item.Identity.ExpertCode, expertName = item.Identity.ExpertName, source = ToWireValue(item.Source) }).ToArray() }); }, message => PostExpertSearchError(requestId, query, message), $"검색어는 {LegacyExpertSelectionService.MaximumQueryLength}자 이하여야 하며 " + $"결과 제한은 1~{LegacyExpertSelectionService.MaximumResults}개입니다.", "전문가 검색 결과의 형식 또는 식별자가 올바르지 않습니다.", "전문가를 검색하지 못했습니다. DB 연결 상태를 확인하세요."); } private void PostExpertSearchError(string requestId, string query, string message) { PostMessage("expert-search-error", new { requestId, query, message }); } private async Task HandleExpertPreviewRequestAsync(JsonElement payload) { var requestId = GetString(payload, "requestId") ?? string.Empty; var expertCode = GetString(payload, "expertCode") ?? string.Empty; var expertName = GetString(payload, "expertName") ?? string.Empty; if (!HasOnlyProperties( payload, "requestId", "expertCode", "expertName", "maximumItems") || !TryGetSafeToken( payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || !payload.TryGetProperty("expertCode", out var codeElement) || codeElement.ValueKind != JsonValueKind.String || !payload.TryGetProperty("expertName", out var nameElement) || nameElement.ValueKind != JsonValueKind.String) { PostExpertPreviewError( requestId, expertCode, expertName, "올바르지 않은 전문가 미리보기 요청입니다."); return; } var maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems; if (payload.TryGetProperty("maximumItems", out var requestedLimit) && (requestedLimit.ValueKind != JsonValueKind.Number || !requestedLimit.TryGetInt32(out maximumItems))) { PostExpertPreviewError( requestId, expertCode, expertName, "전문가 추천종목 제한값이 올바르지 않습니다."); return; } var service = _expertSelectionService; if (service is null) { PostExpertPreviewError( requestId, expertCode, expertName, _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); return; } var identity = new ExpertSelectionIdentity(expertCode, expertName); await ExecuteExpertSelectionRequestAsync( async requestToken => { var result = await service.PreviewAsync( identity, maximumItems, requestToken); requestToken.ThrowIfCancellationRequested(); PostMessage("expert-preview-results", new { requestId, selection = new { expertCode = result.Identity.ExpertCode, expertName = result.Identity.ExpertName }, result.RetrievedAt, totalRowCount = result.Items.Count, truncated = result.IsTruncated, items = result.Items.Select(item => new { playIndex = item.PlayIndex, stockCode = item.StockCode, stockName = item.StockName, buyAmount = item.BuyAmount }).ToArray() }); }, message => PostExpertPreviewError( requestId, expertCode, expertName, message), $"전문가 코드·이름을 확인하고 종목 제한은 " + $"1~{LegacyExpertSelectionService.MaximumPreviewItems}개로 지정하세요.", "전문가 추천종목의 형식, 순서 또는 식별자가 올바르지 않습니다.", "전문가 추천종목을 불러오지 못했습니다. DB 연결 상태를 확인하세요."); } private void PostExpertPreviewError( string requestId, string expertCode, string expertName, string message) { PostMessage("expert-preview-error", new { requestId, selection = new { expertCode, expertName }, message }); } private async Task ExecuteExpertSelectionRequestAsync( Func operation, Action postError, string validationMessage, string invalidDataMessage, string unexpectedMessage) { var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( _lifetimeCancellation.Token); var requestToken = requestCancellation.Token; var previousCancellation = Interlocked.Exchange( ref _expertSelectionCancellation, requestCancellation); CancelRequest(previousCancellation); var databaseActivityEntered = false; try { if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); PostPlayoutPriorityExpertCancellationIfCurrent( postError, requestCancellation); return; } await _databaseActivityGate.WaitAsync(requestToken); databaseActivityEntered = true; requestToken.ThrowIfCancellationRequested(); if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); PostPlayoutPriorityExpertCancellationIfCurrent( postError, requestCancellation); return; } await operation(requestToken); requestToken.ThrowIfCancellationRequested(); } catch (OperationCanceledException) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityExpertCancellationIfCurrent( postError, requestCancellation); } catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityExpertCancellationIfCurrent( postError, requestCancellation); } catch (Exception) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityExpertCancellationIfCurrent( postError, requestCancellation); } catch (ArgumentException) { postError(validationMessage); } catch (ExpertSelectionDataException) { postError(invalidDataMessage); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch (DatabaseOperationException exception) { postError(exception.Message); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch { postError(unexpectedMessage); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } finally { if (databaseActivityEntered) { _databaseActivityGate.Release(); } Interlocked.CompareExchange( ref _expertSelectionCancellation, null, requestCancellation); requestCancellation.Dispose(); } } private void PostPlayoutPriorityExpertCancellationIfCurrent( Action postError, CancellationTokenSource requestCancellation) { if (_lifetimeCancellation.IsCancellationRequested || !ReferenceEquals( Volatile.Read(ref _expertSelectionCancellation), requestCancellation)) { // Shutdown or a newer expert selector request owns the response. return; } postError( "송출 명령을 우선 처리하기 위해 전문가 조회를 취소했습니다. 다시 조회하세요."); } private async Task HandleIndustrySelectionRequestAsync(JsonElement payload) { var requestId = GetString(payload, "requestId") ?? string.Empty; var marketValue = GetString(payload, "market") ?? string.Empty; if (!HasOnlyProperties(payload, "requestId", "market") || !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || !TryParseIndustryMarket(marketValue, out var market)) { PostIndustrySelectionError(requestId, marketValue, "올바르지 않은 업종 목록 요청입니다."); return; } if (_industrySelectionService is null) { PostIndustrySelectionError( requestId, marketValue, _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); return; } var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( _lifetimeCancellation.Token); var requestToken = requestCancellation.Token; var previousCancellation = Interlocked.Exchange( ref _industrySelectionCancellation, requestCancellation); previousCancellation?.Cancel(); var databaseActivityEntered = false; try { if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); PostPlayoutPriorityIndustryCancellationIfCurrent( requestId, marketValue, requestCancellation); return; } await _databaseActivityGate.WaitAsync(requestToken); databaseActivityEntered = true; requestToken.ThrowIfCancellationRequested(); if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); PostPlayoutPriorityIndustryCancellationIfCurrent( requestId, marketValue, requestCancellation); return; } var result = await _industrySelectionService.GetAsync(market, requestToken); requestToken.ThrowIfCancellationRequested(); PostMessage("industry-results", new { requestId, market = ToWireValue(market), totalRowCount = result.Count, results = result.Select(item => new { market = ToWireValue(item.Market), name = item.IndustryName, code = item.IndustryCode }) }); } catch (OperationCanceledException) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityIndustryCancellationIfCurrent( requestId, marketValue, requestCancellation); } catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityIndustryCancellationIfCurrent( requestId, marketValue, requestCancellation); } catch (Exception) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityIndustryCancellationIfCurrent( requestId, marketValue, requestCancellation); } catch (IndustrySelectionDataException) { PostIndustrySelectionError(requestId, marketValue, "업종 목록 결과 형식이 올바르지 않습니다."); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch (DatabaseOperationException exception) { PostIndustrySelectionError(requestId, marketValue, exception.Message); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch { PostIndustrySelectionError( requestId, marketValue, "업종 목록을 불러오지 못했습니다. DB 연결 상태를 확인하세요."); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } finally { if (databaseActivityEntered) { _databaseActivityGate.Release(); } Interlocked.CompareExchange( ref _industrySelectionCancellation, null, requestCancellation); requestCancellation.Dispose(); } } private void PostPlayoutPriorityIndustryCancellationIfCurrent( string requestId, string market, CancellationTokenSource requestCancellation) { if (_lifetimeCancellation.IsCancellationRequested || !ReferenceEquals( Volatile.Read(ref _industrySelectionCancellation), requestCancellation)) { return; } PostIndustrySelectionError( requestId, market, "송출 명령과 자동 갱신을 우선 처리하기 위해 업종 조회를 취소했습니다. 다시 조회하세요."); } private void PostIndustrySelectionError(string requestId, string market, string message) { PostMessage("industry-error", new { requestId, market, message }); } private async Task HandleTradingHaltSelectionRequestAsync(JsonElement payload) { var requestId = GetString(payload, "requestId") ?? string.Empty; var query = GetString(payload, "query") ?? string.Empty; if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") || !TryGetSafeToken( payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || !payload.TryGetProperty("query", out var queryElement) || queryElement.ValueKind != JsonValueKind.String) { PostTradingHaltSelectionError( requestId, query, "올바르지 않은 거래정지 종목 검색 요청입니다."); return; } var maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults; if (payload.TryGetProperty("maximumResults", out var requestedLimit) && (requestedLimit.ValueKind != JsonValueKind.Number || !requestedLimit.TryGetInt32(out maximumResults))) { PostTradingHaltSelectionError( requestId, query, "거래정지 검색 결과 제한값이 올바르지 않습니다."); return; } var service = _tradingHaltSelectionService; if (service is null) { PostTradingHaltSelectionError( requestId, query, _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); return; } var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( _lifetimeCancellation.Token); var requestToken = requestCancellation.Token; var previousCancellation = Interlocked.Exchange( ref _tradingHaltSelectionCancellation, requestCancellation); CancelRequest(previousCancellation); var databaseActivityEntered = false; try { if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); PostPlayoutPriorityTradingHaltCancellationIfCurrent( requestId, query, requestCancellation); return; } await _databaseActivityGate.WaitAsync(requestToken); databaseActivityEntered = true; requestToken.ThrowIfCancellationRequested(); // Playout always owns the shared executor before an operator lookup. if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); PostPlayoutPriorityTradingHaltCancellationIfCurrent( requestId, query, requestCancellation); return; } var result = await service.SearchAsync(query, maximumResults, requestToken); requestToken.ThrowIfCancellationRequested(); PostMessage("trading-halt-results", new { requestId, query = result.Query, result.RetrievedAt, totalRowCount = result.Items.Count, truncated = result.IsTruncated, results = result.Items.Select(item => new { market = ToWireValue(item.Market), stockCode = item.StockCode, displayName = item.DisplayName }).ToArray() }); } catch (OperationCanceledException) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityTradingHaltCancellationIfCurrent( requestId, query, requestCancellation); } catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityTradingHaltCancellationIfCurrent( requestId, query, requestCancellation); } catch (Exception) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityTradingHaltCancellationIfCurrent( requestId, query, requestCancellation); } catch (ArgumentException) { PostTradingHaltSelectionError( requestId, query, $"검색어는 {LegacyTradingHaltSelectionService.MaximumQueryLength}자 이하여야 하며 " + $"결과 제한은 1~{LegacyTradingHaltSelectionService.MaximumResults}개입니다."); } catch (TradingHaltSelectionDataException) { PostTradingHaltSelectionError( requestId, query, "거래정지 종목 검색 결과의 형식 또는 식별자가 올바르지 않습니다."); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch (DatabaseOperationException exception) { PostTradingHaltSelectionError(requestId, query, exception.Message); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch { PostTradingHaltSelectionError( requestId, query, "거래정지 종목을 검색하지 못했습니다. DB 연결 상태를 확인하세요."); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } finally { if (databaseActivityEntered) { _databaseActivityGate.Release(); } Interlocked.CompareExchange( ref _tradingHaltSelectionCancellation, null, requestCancellation); requestCancellation.Dispose(); } } private void PostPlayoutPriorityTradingHaltCancellationIfCurrent( string requestId, string query, CancellationTokenSource requestCancellation) { if (_lifetimeCancellation.IsCancellationRequested || !ReferenceEquals( Volatile.Read(ref _tradingHaltSelectionCancellation), requestCancellation)) { // Shutdown or a newer trading-halt request owns the response. return; } PostTradingHaltSelectionError( requestId, query, "송출 명령을 우선 처리하기 위해 거래정지 검색을 취소했습니다. 다시 검색하세요."); } private void PostTradingHaltSelectionError( string requestId, string query, string message) { PostMessage("trading-halt-error", new { requestId, query, message }); } private async Task PostDatabaseStatusAsync( CancellationToken cancellationToken, bool periodic = false) { if (!_webViewReady) { return; } while (!cancellationToken.IsCancellationRequested) { if (periodic && Volatile.Read(ref _playoutCommandInFlight) != 0) { return; } bool entered; if (periodic) { entered = await _databaseActivityGate.WaitAsync(0, cancellationToken); } else { await _databaseActivityGate.WaitAsync(cancellationToken); entered = true; } if (!entered) { return; } var retryAfterPlayout = false; CancellationTokenSource? healthCancellation = null; try { if (Volatile.Read(ref _playoutCommandInFlight) != 0) { retryAfterPlayout = !periodic; } else { healthCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); Volatile.Write(ref _databaseHealthCancellation, healthCancellation); // Close the race where playout starts after this health request // entered the gate but before its diagnostic query begins. if (Volatile.Read(ref _playoutCommandInFlight) != 0) { healthCancellation.Cancel(); retryAfterPlayout = !periodic; } else { await QueryAndPostDatabaseStatusAsync(healthCancellation.Token); return; } } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return; } catch (OperationCanceledException) when (healthCancellation?.IsCancellationRequested == true) { // Playout has priority over diagnostics. Interactive requests resume // after the command; the periodic monitor keeps the last Web snapshot. retryAfterPlayout = !periodic; } finally { if (healthCancellation is not null) { Interlocked.CompareExchange( ref _databaseHealthCancellation, null, healthCancellation); healthCancellation.Dispose(); } _databaseActivityGate.Release(); } if (!retryAfterPlayout) { return; } // Let the already-active command enter the activity gate before an // interactive health request retries. await Task.Delay(TimeSpan.FromMilliseconds(25), cancellationToken); } } private async Task QueryAndPostDatabaseStatusAsync(CancellationToken cancellationToken) { if (_databaseRuntime is null) { var message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."; ObserveNativeDatabaseState(DataSourceKind.Oracle, DatabaseHealthState.NotConfigured); ObserveNativeDatabaseState(DataSourceKind.MariaDb, DatabaseHealthState.NotConfigured); PostDatabaseStatus(new[] { CreateUnavailableStatus("oracle", message), CreateUnavailableStatus("mariaDb", message) }); return; } try { var statuses = await _databaseRuntime.HealthService .CheckAllAsync(cancellationToken); foreach (var status in statuses) { ObserveNativeDatabaseState(status.Source, status.State); } PostDatabaseStatus(statuses.Select(status => new { source = ToWireValue(status.Source), configured = _databaseRuntime.Options.IsConfigured(status.Source), healthy = status.State == DatabaseHealthState.Healthy, state = status.State.ToString().ToLowerInvariant(), message = status.UserMessage, checkedAt = status.CheckedAt, latencyMs = Math.Max(0, (long)status.Elapsed.TotalMilliseconds) })); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch { ObserveNativeDatabaseState(DataSourceKind.Oracle, DatabaseHealthState.Unhealthy); ObserveNativeDatabaseState(DataSourceKind.MariaDb, DatabaseHealthState.Unhealthy); PostDatabaseStatus(new[] { CreateUnavailableStatus("oracle", "Oracle 상태 확인에 실패했습니다."), CreateUnavailableStatus("mariaDb", "MariaDB 상태 확인에 실패했습니다.") }); } } private void PostDatabaseStatus(object sources) { PostMessage("database-status", new { sequence = Interlocked.Increment(ref _databaseStatusSequence), sources }); } private void CancelActiveDatabaseHealthCheck() { var cancellation = Volatile.Read(ref _databaseHealthCancellation); try { cancellation?.Cancel(); } catch (ObjectDisposedException) { // Completion won the race after the reference was read. } } private void CancelActiveMarketDataRequest() { CancelRequest(Volatile.Read(ref _marketDataCancellation)); CancelRequest(Volatile.Read(ref _stockSearchCancellation)); CancelRequest(Volatile.Read(ref _worldStockSearchCancellation)); CancelRequest(Volatile.Read(ref _overseasIndustrySearchCancellation)); CancelRequest(Volatile.Read(ref _themeSelectionCancellation)); CancelRequest(Volatile.Read(ref _industrySelectionCancellation)); CancelRequest(Volatile.Read(ref _tradingHaltSelectionCancellation)); CancelRequest(Volatile.Read(ref _expertSelectionCancellation)); CancelRequest(Volatile.Read(ref _namedPlaylistReadCancellation)); CancelRequest(Volatile.Read(ref _namedPlaylistWriteCancellation)); CancelRequest(Volatile.Read(ref _playlistPagePlanCancellation)); CancelRequest(Volatile.Read(ref _legacyComparisonImportCancellation)); CancelOperatorCatalogReadsForPlayout(); CancelManualFinancialReadsForPlayout(); } private static void CancelRequest(CancellationTokenSource? cancellation) { try { cancellation?.Cancel(); } catch (ObjectDisposedException) { // Completion won the race after the reference was read. } } private async Task MonitorDatabaseHealthAsync(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) { await PostDatabaseStatusAsync(cancellationToken, periodic: true); try { await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; } } } private static object CreateUnavailableStatus(string source, string message) => new { source, configured = false, healthy = false, state = "notconfigured", message, checkedAt = DateTimeOffset.Now, latencyMs = 0L }; private static string? GetString(JsonElement payload, string propertyName) => payload.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; private static bool TryParseMarketDataView(string? value, out MarketDataView view) { view = value?.ToLowerInvariant() switch { "kospi" => MarketDataView.Kospi, "kosdaq" => MarketDataView.Kosdaq, "index" => MarketDataView.Index, "overseas" => MarketDataView.Overseas, _ => default }; return value?.ToLowerInvariant() is "kospi" or "kosdaq" or "index" or "overseas"; } private static bool TryParseIndustryMarket(string? value, out IndustryMarket market) { market = value?.ToLowerInvariant() switch { "kospi" => IndustryMarket.Kospi, "kosdaq" => IndustryMarket.Kosdaq, _ => default }; return value?.ToLowerInvariant() is "kospi" or "kosdaq"; } private static bool TryParseThemeMarket(string? value, out ThemeMarket market) { market = value?.ToLowerInvariant() switch { "krx" => ThemeMarket.Krx, "nxt" => ThemeMarket.Nxt, _ => default }; return value?.ToLowerInvariant() is "krx" or "nxt"; } private static bool TryParseThemeNxtSession(string? value, out ThemeSession session) { session = value?.ToLowerInvariant() switch { "premarket" => ThemeSession.PreMarket, "aftermarket" => ThemeSession.AfterMarket, _ => default }; return value?.ToLowerInvariant() is "premarket" or "aftermarket"; } private static bool TryParseThemeSession(string? value, out ThemeSession session) { session = value?.ToLowerInvariant() switch { "notapplicable" => ThemeSession.NotApplicable, "premarket" => ThemeSession.PreMarket, "aftermarket" => ThemeSession.AfterMarket, _ => default }; return value?.ToLowerInvariant() is "notapplicable" or "premarket" or "aftermarket"; } private static string ToWireValue(MarketDataView view) => view.ToString().ToLowerInvariant(); private static string ToWireValue(StockMarket market) => market switch { StockMarket.Kospi => "kospi", StockMarket.Kosdaq => "kosdaq", StockMarket.NxtKospi => "nxt-kospi", StockMarket.NxtKosdaq => "nxt-kosdaq", _ => "unknown" }; private static string ToWireValue(IndustryMarket market) => market switch { IndustryMarket.Kospi => "kospi", IndustryMarket.Kosdaq => "kosdaq", _ => "unknown" }; private static string ToWireValue(TradingHaltMarket market) => market switch { TradingHaltMarket.Kospi => "kospi", TradingHaltMarket.Kosdaq => "kosdaq", _ => "unknown" }; private static string ToWireValue(ThemeMarket market) => market switch { ThemeMarket.Krx => "krx", ThemeMarket.Nxt => "nxt", _ => "unknown" }; private static string ToWireValue(ThemeSession session) => session switch { ThemeSession.NotApplicable => "notApplicable", ThemeSession.PreMarket => "preMarket", ThemeSession.AfterMarket => "afterMarket", _ => "unknown" }; private static string ToWireValue(DataSourceKind source) => source switch { DataSourceKind.Oracle => "oracle", DataSourceKind.MariaDb => "mariaDb", _ => "unknown" }; private void PostAppInfo() { var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "1.0.0"; PostMessage("app-info", new { name = "MBN Stock WebView", version, framework = ".NET 8 / WinUI 3", windowsAppSdk = "1.8", architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), package = "MSIX", migratedQueryFiles = 71, databaseConfigured = _databaseRuntime is not null && new[] { DataSourceKind.Oracle, DataSourceKind.MariaDb } .Any(_databaseRuntime.Options.IsConfigured) }); } private void PostMessage(string type, object payload) { if (!_webViewReady) { return; } Browser.CoreWebView2.PostWebMessageAsJson( JsonSerializer.Serialize(new { type, payload }, JsonOptions)); } private static async Task OpenExternalUriAsync(Uri uri) { if (uri.Scheme is "https" or "http") { await Launcher.LaunchUriAsync(uri); } } private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args) { QuarantineIfBrowserCorrelationCanBeLost(); ShowError("WebView2 프로세스가 종료되었습니다.", args.ProcessFailedKind.ToString()); } private void ShowError(string title, string message) { LoadingOverlay.Visibility = Visibility.Collapsed; ErrorBar.Title = title; ErrorBar.Message = message; ErrorBar.IsOpen = true; } private void OnBackClicked(object sender, RoutedEventArgs e) { if (Browser.CanGoBack) { Browser.GoBack(); } } private void OnForwardClicked(object sender, RoutedEventArgs e) { if (Browser.CanGoForward) { Browser.GoForward(); } } private void OnReloadClicked(object sender, RoutedEventArgs e) { if (_webViewReady) { ReloadBrowserSafely(); } } private void ReloadBrowserSafely() { QuarantineIfBrowserCorrelationCanBeLost(); Browser.Reload(); } private void QuarantineIfBrowserCorrelationCanBeLost() { InvalidateNamedPlaylistBrowserRequests(); InvalidatePlaylistPagePlanBrowserRequest(); InvalidateOperatorCatalogBrowserRequests(); InvalidateManualOperatorBrowserRequests(); InvalidateManualFinancialBrowserRequests(); InvalidateLegacyComparisonImportRequest(); if (Volatile.Read(ref _playoutCommandInFlight) != 0) { // Navigation or a renderer failure destroys the JavaScript request // correlation. Latch native OutcomeUnknown before the document can go away. _ = QuarantineForBrowserCorrelationLossAsync(); } } private void OnClosed(object sender, WindowEventArgs args) { DetachCloseConfirmation(); LogNativeOperatorShutdown(); var wasWebViewReady = _webViewReady; _webViewReady = false; _lifetimeCancellation.Cancel(); CancelActiveDatabaseHealthCheck(); ShutdownPlayoutRuntime(); var requestCancellation = Interlocked.Exchange(ref _marketDataCancellation, null); requestCancellation?.Cancel(); var stockSearchCancellation = Interlocked.Exchange(ref _stockSearchCancellation, null); stockSearchCancellation?.Cancel(); var worldStockSearchCancellation = Interlocked.Exchange( ref _worldStockSearchCancellation, null); worldStockSearchCancellation?.Cancel(); var overseasIndustrySearchCancellation = Interlocked.Exchange( ref _overseasIndustrySearchCancellation, null); overseasIndustrySearchCancellation?.Cancel(); var themeSelectionCancellation = Interlocked.Exchange( ref _themeSelectionCancellation, null); themeSelectionCancellation?.Cancel(); var industrySelectionCancellation = Interlocked.Exchange( ref _industrySelectionCancellation, null); industrySelectionCancellation?.Cancel(); var tradingHaltSelectionCancellation = Interlocked.Exchange( ref _tradingHaltSelectionCancellation, null); CancelRequest(tradingHaltSelectionCancellation); var expertSelectionCancellation = Interlocked.Exchange( ref _expertSelectionCancellation, null); CancelRequest(expertSelectionCancellation); var namedPlaylistReadCancellation = Interlocked.Exchange( ref _namedPlaylistReadCancellation, null); CancelRequest(namedPlaylistReadCancellation); var namedPlaylistWriteCancellation = Interlocked.Exchange( ref _namedPlaylistWriteCancellation, null); CancelRequest(namedPlaylistWriteCancellation); var playlistPagePlanCancellation = Interlocked.Exchange( ref _playlistPagePlanCancellation, null); CancelRequest(playlistPagePlanCancellation); ShutdownOperatorCatalogRuntime(); ShutdownManualFinancialRuntime(); ShutdownManualOperatorDataRuntime(); ShutdownLegacyComparisonImportRuntime(); DataQueryExecutor.Reset(); if (!wasWebViewReady) { _lifetimeCancellation.Dispose(); return; } Browser.CoreWebView2.NavigationStarting -= OnNavigationStarting; Browser.CoreWebView2.NavigationCompleted -= OnNavigationCompleted; Browser.CoreWebView2.HistoryChanged -= OnHistoryChanged; Browser.CoreWebView2.NewWindowRequested -= OnNewWindowRequested; Browser.CoreWebView2.WebMessageReceived -= OnWebMessageReceived; Browser.CoreWebView2.ProcessFailed -= OnProcessFailed; _lifetimeCancellation.Dispose(); } private sealed record WebRequest(string Type, JsonElement Payload); }