feat: complete Oracle and MariaDB WebView data layer
This commit is contained in:
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using MMoneyCoderSharp.Data;
|
||||
@@ -15,20 +16,51 @@ 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 DatabaseRuntime? _databaseRuntime;
|
||||
private IMarketDataService? _marketDataService;
|
||||
private CancellationTokenSource? _marketDataCancellation;
|
||||
private string? _databaseInitializationError;
|
||||
private bool _webViewReady;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeDatabaseRuntime();
|
||||
Root.Loaded += OnRootLoaded;
|
||||
Closed += OnClosed;
|
||||
}
|
||||
|
||||
private void InitializeDatabaseRuntime()
|
||||
{
|
||||
try
|
||||
{
|
||||
_databaseRuntime = DatabaseRuntime.CreateDefault();
|
||||
DataQueryExecutor.Configure(_databaseRuntime.Executor);
|
||||
_marketDataService = new LegacyMarketDataService(_databaseRuntime.Executor);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private void ResizeWindow()
|
||||
@@ -139,6 +171,13 @@ public sealed partial class MainWindow : Window
|
||||
case "ready":
|
||||
case "request-app-info":
|
||||
PostAppInfo();
|
||||
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
|
||||
break;
|
||||
case "request-database-status":
|
||||
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
|
||||
break;
|
||||
case "request-market-data" when request.Payload.ValueKind == JsonValueKind.Object:
|
||||
_ = HandleMarketDataRequestAsync(request.Payload);
|
||||
break;
|
||||
case "open-external" when request.Payload.ValueKind == JsonValueKind.Object:
|
||||
if (request.Payload.TryGetProperty("url", out var value) &&
|
||||
@@ -156,8 +195,232 @@ public sealed partial class MainWindow : Window
|
||||
{
|
||||
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 previousCancellation = Interlocked.Exchange(ref _marketDataCancellation, requestCancellation);
|
||||
previousCancellation?.Cancel();
|
||||
previousCancellation?.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = await _marketDataService
|
||||
.GetAsync(view, maximumRows, requestCancellation.Token);
|
||||
|
||||
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 (requestCancellation.IsCancellationRequested)
|
||||
{
|
||||
// A newer navigation request or app shutdown superseded this response.
|
||||
}
|
||||
catch (DatabaseOperationException exception)
|
||||
{
|
||||
PostMarketDataError(requestId, view, exception.Message);
|
||||
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
|
||||
}
|
||||
catch
|
||||
{
|
||||
PostMarketDataError(requestId, view, "시장 데이터를 조회하지 못했습니다. DB 연결 상태를 확인하세요.");
|
||||
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(Interlocked.CompareExchange(
|
||||
ref _marketDataCancellation,
|
||||
null,
|
||||
requestCancellation), requestCancellation))
|
||||
{
|
||||
requestCancellation.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PostMarketDataError(string requestId, MarketDataView view, string message)
|
||||
{
|
||||
PostMessage("market-data-error", new
|
||||
{
|
||||
requestId,
|
||||
view = ToWireValue(view),
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
private async Task PostDatabaseStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_webViewReady)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_databaseRuntime is null)
|
||||
{
|
||||
var message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.";
|
||||
PostMessage("database-status", new
|
||||
{
|
||||
sources = new[]
|
||||
{
|
||||
CreateUnavailableStatus("oracle", message),
|
||||
CreateUnavailableStatus("mariaDb", message)
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var statuses = await _databaseRuntime.HealthService
|
||||
.CheckAllAsync(cancellationToken);
|
||||
|
||||
PostMessage("database-status", new
|
||||
{
|
||||
sources = 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)
|
||||
{
|
||||
}
|
||||
catch
|
||||
{
|
||||
PostMessage("database-status", new
|
||||
{
|
||||
sources = new[]
|
||||
{
|
||||
CreateUnavailableStatus("oracle", "Oracle 상태 확인에 실패했습니다."),
|
||||
CreateUnavailableStatus("mariaDb", "MariaDB 상태 확인에 실패했습니다.")
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MonitorDatabaseHealthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await PostDatabaseStatusAsync(cancellationToken);
|
||||
|
||||
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 string ToWireValue(MarketDataView view) => view.ToString().ToLowerInvariant();
|
||||
|
||||
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";
|
||||
@@ -171,7 +434,9 @@ public sealed partial class MainWindow : Window
|
||||
architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(),
|
||||
package = "MSIX",
|
||||
migratedQueryFiles = 71,
|
||||
databaseConfigured = DataQueryExecutor.IsConfigured
|
||||
databaseConfigured = _databaseRuntime is not null &&
|
||||
new[] { DataSourceKind.Oracle, DataSourceKind.MariaDb }
|
||||
.Any(_databaseRuntime.Options.IsConfigured)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -233,8 +498,17 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
private void OnClosed(object sender, WindowEventArgs args)
|
||||
{
|
||||
if (!_webViewReady)
|
||||
var wasWebViewReady = _webViewReady;
|
||||
_webViewReady = false;
|
||||
_lifetimeCancellation.Cancel();
|
||||
var requestCancellation = Interlocked.Exchange(ref _marketDataCancellation, null);
|
||||
requestCancellation?.Cancel();
|
||||
requestCancellation?.Dispose();
|
||||
DataQueryExecutor.Reset();
|
||||
|
||||
if (!wasWebViewReady)
|
||||
{
|
||||
_lifetimeCancellation.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -244,6 +518,7 @@ public sealed partial class MainWindow : Window
|
||||
Browser.CoreWebView2.NewWindowRequested -= OnNewWindowRequested;
|
||||
Browser.CoreWebView2.WebMessageReceived -= OnWebMessageReceived;
|
||||
Browser.CoreWebView2.ProcessFailed -= OnProcessFailed;
|
||||
_lifetimeCancellation.Dispose();
|
||||
}
|
||||
|
||||
private sealed record WebRequest(string Type, JsonElement Payload);
|
||||
|
||||
Reference in New Issue
Block a user