2659 lines
97 KiB
C#
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);
|
|
}
|