Files
MBN_STOCK_WEBVIEW/MainWindow.xaml.cs

2659 lines
97 KiB
C#

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();
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;
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<WebRequest>(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;
}
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 "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;
case "open-external" when request.Payload.ValueKind == JsonValueKind.Object:
if (request.Payload.TryGetProperty("url", out var value) &&
Uri.TryCreate(value.GetString(), UriKind.Absolute, out var uri))
{
_ = OpenExternalUriAsync(uri);
}
break;
case "reload":
ReloadBrowserSafely();
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<CancellationToken, Task> operation,
Action<string> 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<string> 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<CancellationToken, Task> operation,
Action<string> 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<string> 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 ?? "데이터베이스가 설정되지 않았습니다.";
PostDatabaseStatus(new[]
{
CreateUnavailableStatus("oracle", message),
CreateUnavailableStatus("mariaDb", message)
});
return;
}
try
{
var statuses = await _databaseRuntime.HealthService
.CheckAllAsync(cancellationToken);
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
{
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));
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();
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)
{
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();
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);
}