feat: establish legacy parity migration baseline

This commit is contained in:
2026-07-15 07:10:01 +09:00
parent e468f43886
commit f14800656b
36 changed files with 3166 additions and 0 deletions

View File

@@ -0,0 +1,299 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using MBN_STOCK_WEBVIEW.LegacyApplication;
namespace MBN_STOCK_WEBVIEW.LegacyBridge;
public abstract record LegacyUiIntent;
public sealed record LegacyReadyIntent : LegacyUiIntent;
public sealed record LegacySearchStocksIntent(string Text, LegacySearchTrigger Trigger)
: LegacyUiIntent;
public sealed record LegacySelectStockIntent(int ResultIndex) : LegacyUiIntent;
public sealed record LegacyCutPointerDownIntent(
int PhysicalIndex,
bool Control,
bool Shift,
int X,
int Y,
long TimestampMilliseconds) : LegacyUiIntent;
public sealed record LegacyDropSelectedCutsIntent : LegacyUiIntent;
public sealed record LegacySetPlaylistEnabledIntent(string RowId, bool Enabled)
: LegacyUiIntent;
public sealed record LegacyDismissDialogIntent : LegacyUiIntent;
public static class LegacyBridgeProtocol
{
public const string TrustedHost = "legacy-parity.mbn.local";
private static readonly JsonSerializerOptions SerializerOptions = CreateSerializerOptions();
public static bool IsTrustedSource(string? source)
{
if (!Uri.TryCreate(source, UriKind.Absolute, out var uri))
{
return false;
}
return string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) &&
string.Equals(uri.Host, TrustedHost, StringComparison.OrdinalIgnoreCase) &&
uri.IsDefaultPort;
}
public static bool TryParseIntent(
string json,
out LegacyUiIntent? intent,
out string? error)
{
intent = null;
error = null;
try
{
using var document = JsonDocument.Parse(json, new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow,
MaxDepth = 8
});
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(root, "type", "payload") ||
!TryString(root, "type", 64, out var type) ||
!root.TryGetProperty("payload", out var payload) ||
payload.ValueKind != JsonValueKind.Object)
{
error = "올바르지 않은 UI 요청입니다.";
return false;
}
intent = type switch
{
"ready" => ParseReady(payload),
"search-stocks" => ParseSearch(payload),
"select-stock" => ParseStockSelection(payload),
"cut-pointer-down" => ParseCutPointer(payload),
"drop-selected-cuts" => ParseDrop(payload),
"set-playlist-enabled" => ParseEnabled(payload),
"dismiss-dialog" => ParseDismiss(payload),
_ => null
};
if (intent is null)
{
error = "지원하지 않는 UI 요청입니다.";
return false;
}
return true;
}
catch (JsonException)
{
error = "UI 요청 JSON을 읽을 수 없습니다.";
return false;
}
}
public static string SerializeState(
LegacyOperatorSnapshot snapshot,
bool isBusy = false)
{
ArgumentNullException.ThrowIfNull(snapshot);
var payload = new
{
snapshot.Revision,
snapshot.SearchText,
snapshot.SearchResults,
snapshot.SelectedStockIndex,
snapshot.CutRows,
playlist = snapshot.Playlist.Select(row => new
{
row.RowId,
row.IsEnabled,
row.MarketText,
row.StockName,
row.GraphicType,
row.Subtype,
row.PageText
}),
snapshot.Dialog,
snapshot.StatusMessage,
snapshot.StatusKind,
isBusy
};
return JsonSerializer.Serialize(new { type = "state", payload }, SerializerOptions);
}
private static LegacyUiIntent? ParseReady(JsonElement payload) =>
HasOnlyProperties(payload) ? new LegacyReadyIntent() : null;
private static LegacyUiIntent? ParseSearch(JsonElement payload)
{
if (!HasOnlyProperties(payload, "text", "trigger") ||
!TryString(
payload,
"text",
LegacyParityStockSearchService.LegacyTextBoxMaximumLength,
out var text) ||
!TryString(payload, "trigger", 16, out var triggerText))
{
return null;
}
var trigger = triggerText switch
{
"button" => LegacySearchTrigger.Button,
"enter" => LegacySearchTrigger.Enter,
_ => (LegacySearchTrigger?)null
};
return trigger.HasValue ? new LegacySearchStocksIntent(text, trigger.Value) : null;
}
private static LegacyUiIntent? ParseStockSelection(JsonElement payload) =>
HasOnlyProperties(payload, "resultIndex") &&
TryInt32(payload, "resultIndex", 0, int.MaxValue, out var resultIndex)
? new LegacySelectStockIntent(resultIndex)
: null;
private static LegacyUiIntent? ParseCutPointer(JsonElement payload)
{
if (!HasOnlyProperties(
payload,
"physicalIndex",
"control",
"shift",
"x",
"y",
"timestampMilliseconds") ||
!TryInt32(payload, "physicalIndex", 0, 32, out var physicalIndex) ||
!TryBoolean(payload, "control", out var control) ||
!TryBoolean(payload, "shift", out var shift) ||
!TryInt32(payload, "x", 0, 16_384, out var x) ||
!TryInt32(payload, "y", 0, 16_384, out var y) ||
!TryInt64(payload, "timestampMilliseconds", 0, long.MaxValue, out var timestamp))
{
return null;
}
return new LegacyCutPointerDownIntent(
physicalIndex,
control,
shift,
x,
y,
timestamp);
}
private static LegacyUiIntent? ParseDrop(JsonElement payload) =>
HasOnlyProperties(payload) ? new LegacyDropSelectedCutsIntent() : null;
private static LegacyUiIntent? ParseEnabled(JsonElement payload)
{
if (!HasOnlyProperties(payload, "rowId", "enabled") ||
!TryString(payload, "rowId", 128, out var rowId) ||
!IsSafeIdentifier(rowId) ||
!TryBoolean(payload, "enabled", out var enabled))
{
return null;
}
return new LegacySetPlaylistEnabledIntent(rowId, enabled);
}
private static LegacyUiIntent? ParseDismiss(JsonElement payload) =>
HasOnlyProperties(payload) ? new LegacyDismissDialogIntent() : null;
private static bool HasOnlyProperties(JsonElement element, params string[] names)
{
var expected = new HashSet<string>(names, StringComparer.Ordinal);
var count = 0;
foreach (var property in element.EnumerateObject())
{
count++;
if (!expected.Contains(property.Name))
{
return false;
}
}
return count == expected.Count;
}
private static bool TryString(
JsonElement element,
string propertyName,
int maximumLength,
out string value)
{
value = string.Empty;
return element.TryGetProperty(propertyName, out var property) &&
property.ValueKind == JsonValueKind.String &&
(value = property.GetString() ?? string.Empty).Length <= maximumLength;
}
private static bool TryBoolean(JsonElement element, string name, out bool value)
{
value = false;
if (!element.TryGetProperty(name, out var property) ||
(property.ValueKind != JsonValueKind.True && property.ValueKind != JsonValueKind.False))
{
return false;
}
value = property.GetBoolean();
return true;
}
private static bool TryInt32(
JsonElement element,
string name,
int minimum,
int maximum,
out int value)
{
value = 0;
return element.TryGetProperty(name, out var property) &&
property.ValueKind == JsonValueKind.Number &&
property.TryGetInt32(out value) &&
value >= minimum && value <= maximum;
}
private static bool TryInt64(
JsonElement element,
string name,
long minimum,
long maximum,
out long value)
{
value = 0;
return element.TryGetProperty(name, out var property) &&
property.ValueKind == JsonValueKind.Number &&
property.TryGetInt64(out value) &&
value >= minimum && value <= maximum;
}
private static bool IsSafeIdentifier(string value)
{
if (value.Length == 0)
{
return false;
}
return value.All(character =>
char.IsAsciiLetterOrDigit(character) || character is '-' or '_');
}
private static JsonSerializerOptions CreateSerializerOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
return options;
}
}