feat: complete Oracle and MariaDB WebView data layer

This commit is contained in:
2026-07-10 05:33:19 +09:00
parent 5aa90e4aaa
commit 39c4504b87
44 changed files with 3956 additions and 46 deletions

View File

@@ -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);