feat: complete legacy UI and playout parity migration
This commit is contained in:
@@ -0,0 +1,558 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyExpertRequestStatus
|
||||
{
|
||||
Idle,
|
||||
Loading,
|
||||
Ready,
|
||||
Error
|
||||
}
|
||||
|
||||
public sealed record LegacyExpertResultView(
|
||||
string SelectionId,
|
||||
string ExpertCode,
|
||||
string ExpertName);
|
||||
|
||||
public sealed record LegacyExpertRecommendationView(
|
||||
int PlayIndex,
|
||||
string StockCode,
|
||||
string StockName,
|
||||
decimal BuyAmount);
|
||||
|
||||
public sealed record LegacyExpertSearchSnapshot(
|
||||
string Query,
|
||||
LegacyExpertRequestStatus Status,
|
||||
IReadOnlyList<LegacyExpertResultView> Results,
|
||||
bool IsTruncated,
|
||||
string ErrorMessage);
|
||||
|
||||
public sealed record LegacyExpertPreviewSnapshot(
|
||||
LegacyExpertRequestStatus Status,
|
||||
IReadOnlyList<LegacyExpertRecommendationView> Items,
|
||||
bool IsTruncated,
|
||||
int PageSize,
|
||||
int PageCount,
|
||||
int MaximumPageCount,
|
||||
string ErrorMessage);
|
||||
|
||||
public sealed record LegacyExpertActionView(
|
||||
string ActionId,
|
||||
string Label,
|
||||
bool IsAvailable);
|
||||
|
||||
public sealed record LegacyExpertWorkflowSnapshot(
|
||||
long Revision,
|
||||
LegacyExpertSearchSnapshot Search,
|
||||
LegacyExpertResultView? SelectedExpert,
|
||||
LegacyExpertPreviewSnapshot Preview,
|
||||
IReadOnlyList<LegacyExpertActionView> Actions,
|
||||
string StatusMessage,
|
||||
LegacyOperatorStatusKind StatusKind);
|
||||
|
||||
public sealed record LegacyExpertPlaylistDraft(
|
||||
string DraftId,
|
||||
string BuilderKey,
|
||||
string CutCode,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
string Source,
|
||||
string ExpertCode,
|
||||
string ExpertName,
|
||||
int ItemCount,
|
||||
bool PreviewTruncated,
|
||||
int PageSize,
|
||||
int PageCount,
|
||||
int MaximumPageCount,
|
||||
string PageText,
|
||||
LegacySceneSelection Selection,
|
||||
bool IsEnabled,
|
||||
int FadeDuration,
|
||||
string ActionId)
|
||||
{
|
||||
public LegacyPlaylistEntry ToLegacyPlaylistEntry() =>
|
||||
new(DraftId, CutCode, IsEnabled, FadeDuration, Selection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Native authority for UC6's read-only expert selector and recommendation preview.
|
||||
/// EList CRUD is intentionally outside this workflow.
|
||||
/// </summary>
|
||||
public sealed class LegacyExpertWorkflowController
|
||||
{
|
||||
public const string ActionId = "five-row-current";
|
||||
public const int PageSize = 5;
|
||||
public const int MaximumPageCount = 20;
|
||||
public const int MaximumAccessibleItems = PageSize * MaximumPageCount;
|
||||
|
||||
private const int DefaultFadeDuration = 6;
|
||||
private const string ActionLabel = "전문가 추천 · 5단 표그래프 · 현재가";
|
||||
|
||||
private static readonly ReadOnlyCollection<string> Aliases =
|
||||
Array.AsReadOnly(new[] { "5074" });
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly IExpertSelectionService _service;
|
||||
private List<ExpertState> _searchResults = [];
|
||||
private ExpertState? _selected;
|
||||
private List<ExpertRecommendationPreview> _previewItems = [];
|
||||
private string _query = string.Empty;
|
||||
private string _searchError = string.Empty;
|
||||
private string _previewError = string.Empty;
|
||||
private string _statusMessage = string.Empty;
|
||||
private LegacyExpertRequestStatus _searchStatus = LegacyExpertRequestStatus.Idle;
|
||||
private LegacyExpertRequestStatus _previewStatus = LegacyExpertRequestStatus.Idle;
|
||||
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
|
||||
private bool _searchTruncated;
|
||||
private bool _previewTruncated;
|
||||
private int _searchGeneration;
|
||||
private int _previewGeneration;
|
||||
private long _revision;
|
||||
private long _draftSequence;
|
||||
|
||||
public LegacyExpertWorkflowController(IExpertSelectionService service)
|
||||
{
|
||||
_service = service ?? throw new ArgumentNullException(nameof(service));
|
||||
}
|
||||
|
||||
public LegacyExpertWorkflowSnapshot Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyExpertWorkflowSnapshot> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = NormalizeQuery(query);
|
||||
int generation;
|
||||
lock (_gate)
|
||||
{
|
||||
generation = ++_searchGeneration;
|
||||
_previewGeneration++;
|
||||
_query = normalizedQuery;
|
||||
_searchResults = [];
|
||||
_selected = null;
|
||||
ResetPreview();
|
||||
_searchStatus = LegacyExpertRequestStatus.Loading;
|
||||
_searchError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _service.SearchAsync(
|
||||
normalizedQuery,
|
||||
LegacyExpertSelectionService.MaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation != _searchGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_searchResults = ValidateSearchResult(result, normalizedQuery, generation);
|
||||
_searchTruncated = result.IsTruncated;
|
||||
_searchStatus = LegacyExpertRequestStatus.Ready;
|
||||
_searchError = string.Empty;
|
||||
_statusMessage = $"전문가 {_searchResults.Count}명을 조회했습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Information;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _searchGeneration)
|
||||
{
|
||||
_searchStatus = LegacyExpertRequestStatus.Idle;
|
||||
_searchError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _searchGeneration)
|
||||
{
|
||||
_searchResults = [];
|
||||
_searchStatus = LegacyExpertRequestStatus.Error;
|
||||
_searchError = "전문가 조회 결과를 안전하게 확인하지 못했습니다.";
|
||||
_statusMessage = _searchError;
|
||||
_statusKind = LegacyOperatorStatusKind.Error;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyExpertWorkflowSnapshot> SelectExpertAsync(
|
||||
string selectionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selectionId);
|
||||
ExpertState selected;
|
||||
int generation;
|
||||
lock (_gate)
|
||||
{
|
||||
selected = _searchResults.FirstOrDefault(item =>
|
||||
string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)) ??
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(selectionId),
|
||||
"The expert selection id is not in the current result set.");
|
||||
_selected = selected;
|
||||
generation = ++_previewGeneration;
|
||||
_previewItems = [];
|
||||
_previewTruncated = false;
|
||||
_previewStatus = LegacyExpertRequestStatus.Loading;
|
||||
_previewError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _service.PreviewAsync(
|
||||
selected.Identity,
|
||||
LegacyExpertSelectionService.MaximumPreviewItems,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation != _previewGeneration ||
|
||||
_selected?.Identity != selected.Identity)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_previewItems = ValidatePreviewResult(result, selected.Identity);
|
||||
_previewTruncated = result.IsTruncated;
|
||||
_previewStatus = LegacyExpertRequestStatus.Ready;
|
||||
_previewError = string.Empty;
|
||||
_statusMessage = $"{selected.Identity.ExpertName} 추천 종목 {_previewItems.Count}개를 조회했습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Information;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _previewGeneration)
|
||||
{
|
||||
_previewStatus = LegacyExpertRequestStatus.Idle;
|
||||
_previewError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _previewGeneration)
|
||||
{
|
||||
_previewItems = [];
|
||||
_previewTruncated = false;
|
||||
_previewStatus = LegacyExpertRequestStatus.Error;
|
||||
_previewError = "추천 종목 preview를 안전하게 확인하지 못했습니다.";
|
||||
_statusMessage = _previewError;
|
||||
_statusKind = LegacyOperatorStatusKind.Error;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyExpertPlaylistDraft MaterializeSelectedAction(string actionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!string.Equals(actionId, ActionId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(actionId));
|
||||
}
|
||||
|
||||
if (_selected is null || _previewStatus != LegacyExpertRequestStatus.Ready)
|
||||
{
|
||||
throw new InvalidOperationException("Select and preview an expert first.");
|
||||
}
|
||||
|
||||
var itemCount = _previewItems.Count;
|
||||
if (itemCount is <= 0 or > MaximumAccessibleItems)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"An expert cut requires one through 100 accessible recommendation rows.");
|
||||
}
|
||||
|
||||
var pageCount = checked((itemCount + PageSize - 1) / PageSize);
|
||||
if (pageCount is <= 0 or > MaximumPageCount)
|
||||
{
|
||||
throw new InvalidOperationException("The expert PageN plan exceeds 20 pages.");
|
||||
}
|
||||
|
||||
var identity = _selected.Identity;
|
||||
var selection = new LegacySceneSelection(
|
||||
"PAGED_EXPERT",
|
||||
identity.ExpertName,
|
||||
"INPUT_ORDER",
|
||||
"CURRENT",
|
||||
identity.ExpertCode);
|
||||
return new LegacyExpertPlaylistDraft(
|
||||
$"expert-draft-{++_draftSequence:D8}",
|
||||
"s5074",
|
||||
"5074",
|
||||
Aliases,
|
||||
identity.ExpertName,
|
||||
ActionLabel,
|
||||
"expert",
|
||||
"legacy-expert-workflow",
|
||||
identity.ExpertCode,
|
||||
identity.ExpertName,
|
||||
itemCount,
|
||||
_previewTruncated,
|
||||
PageSize,
|
||||
pageCount,
|
||||
MaximumPageCount,
|
||||
$"1/{pageCount}",
|
||||
selection,
|
||||
true,
|
||||
DefaultFadeDuration,
|
||||
ActionId);
|
||||
}
|
||||
}
|
||||
|
||||
private LegacyExpertWorkflowSnapshot CreateSnapshot()
|
||||
{
|
||||
var searchRows = _searchResults.Select(ToView).ToArray();
|
||||
var previewRows = _previewItems.Select(item =>
|
||||
new LegacyExpertRecommendationView(
|
||||
item.PlayIndex,
|
||||
item.StockCode,
|
||||
item.StockName,
|
||||
item.BuyAmount)).ToArray();
|
||||
var pageCount = previewRows.Length == 0
|
||||
? 0
|
||||
: (previewRows.Length + PageSize - 1) / PageSize;
|
||||
var actionAvailable = _selected is not null &&
|
||||
_previewStatus == LegacyExpertRequestStatus.Ready &&
|
||||
previewRows.Length is > 0 and <= MaximumAccessibleItems &&
|
||||
pageCount <= MaximumPageCount;
|
||||
return new LegacyExpertWorkflowSnapshot(
|
||||
_revision,
|
||||
new LegacyExpertSearchSnapshot(
|
||||
_query,
|
||||
_searchStatus,
|
||||
Array.AsReadOnly(searchRows),
|
||||
_searchTruncated,
|
||||
_searchError),
|
||||
_selected is null ? null : ToView(_selected),
|
||||
new LegacyExpertPreviewSnapshot(
|
||||
_previewStatus,
|
||||
Array.AsReadOnly(previewRows),
|
||||
_previewTruncated,
|
||||
PageSize,
|
||||
pageCount,
|
||||
MaximumPageCount,
|
||||
_previewError),
|
||||
Array.AsReadOnly(new[]
|
||||
{
|
||||
new LegacyExpertActionView(ActionId, ActionLabel, actionAvailable)
|
||||
}),
|
||||
_statusMessage,
|
||||
_statusKind);
|
||||
}
|
||||
|
||||
private static List<ExpertState> ValidateSearchResult(
|
||||
ExpertSearchResult result,
|
||||
string expectedQuery,
|
||||
int generation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) ||
|
||||
result.Items is null ||
|
||||
result.Items.Count > LegacyExpertSelectionService.MaximumResults ||
|
||||
result.IsTruncated &&
|
||||
result.Items.Count != LegacyExpertSelectionService.MaximumResults)
|
||||
{
|
||||
throw new ExpertSelectionDataException("The expert search response correlation is invalid.");
|
||||
}
|
||||
|
||||
var states = new List<ExpertState>(result.Items.Count);
|
||||
var codes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
ExpertSelectionIdentity? previous = null;
|
||||
for (var index = 0; index < result.Items.Count; index++)
|
||||
{
|
||||
var item = result.Items[index] ??
|
||||
throw new ExpertSelectionDataException("The expert search row is missing.");
|
||||
if (item.Source != DataSourceKind.Oracle)
|
||||
{
|
||||
throw new ExpertSelectionDataException("Only Oracle expert identities are selectable.");
|
||||
}
|
||||
|
||||
ValidateExpertIdentity(item.Identity);
|
||||
if (!codes.Add(item.Identity.ExpertCode) || !names.Add(item.Identity.ExpertName) ||
|
||||
previous is not null && CompareIdentities(previous, item.Identity) > 0)
|
||||
{
|
||||
throw new ExpertSelectionDataException(
|
||||
"Expert identities must be unique and sorted by name then code.");
|
||||
}
|
||||
|
||||
states.Add(new ExpertState(
|
||||
$"expert-result-{generation:D4}-{index + 1:D4}",
|
||||
item.Identity));
|
||||
previous = item.Identity;
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
private static List<ExpertRecommendationPreview> ValidatePreviewResult(
|
||||
ExpertPreviewResult result,
|
||||
ExpertSelectionIdentity expectedIdentity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ValidateExpertIdentity(result.Identity);
|
||||
if (result.Identity != expectedIdentity || result.Items is null ||
|
||||
result.Items.Count > MaximumAccessibleItems ||
|
||||
result.IsTruncated && result.Items.Count != MaximumAccessibleItems)
|
||||
{
|
||||
throw new ExpertSelectionDataException("The expert preview response correlation is invalid.");
|
||||
}
|
||||
|
||||
var items = new List<ExpertRecommendationPreview>(result.Items.Count);
|
||||
var indexes = new HashSet<int>();
|
||||
var codes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
var previousIndex = -1;
|
||||
foreach (var item in result.Items)
|
||||
{
|
||||
if (item is null || item.PlayIndex is < 0 or > 9_999 ||
|
||||
item.PlayIndex <= previousIndex ||
|
||||
!IsAsciiAlphaNumeric(item.StockCode, 32) ||
|
||||
!IsCanonicalText(item.StockName, 200) ||
|
||||
item.BuyAmount <= 0 || item.BuyAmount > 9_007_199_254_740_991m ||
|
||||
decimal.Truncate(item.BuyAmount) != item.BuyAmount ||
|
||||
!indexes.Add(item.PlayIndex) ||
|
||||
!codes.Add(item.StockCode) ||
|
||||
!names.Add(item.StockName))
|
||||
{
|
||||
throw new ExpertSelectionDataException(
|
||||
"The expert preview contains an invalid or duplicate recommendation.");
|
||||
}
|
||||
|
||||
items.Add(item);
|
||||
previousIndex = item.PlayIndex;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static void ValidateExpertIdentity(ExpertSelectionIdentity identity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(identity);
|
||||
if (identity.ExpertCode.Length != 4 ||
|
||||
identity.ExpertCode.Any(static character => !char.IsAsciiDigit(character)) ||
|
||||
!IsCanonicalText(identity.ExpertName, 128))
|
||||
{
|
||||
throw new ExpertSelectionDataException("The expert identity is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
private static int CompareIdentities(
|
||||
ExpertSelectionIdentity left,
|
||||
ExpertSelectionIdentity right)
|
||||
{
|
||||
var name = string.Compare(left.ExpertName, right.ExpertName, StringComparison.Ordinal);
|
||||
return name != 0
|
||||
? name
|
||||
: string.Compare(left.ExpertCode, right.ExpertCode, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static LegacyExpertResultView ToView(ExpertState state) =>
|
||||
new(state.SelectionId, state.Identity.ExpertCode, state.Identity.ExpertName);
|
||||
|
||||
private static string NormalizeQuery(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (!IsSafeText(query))
|
||||
{
|
||||
throw new ArgumentException("The expert query contains an unsafe character.", nameof(query));
|
||||
}
|
||||
|
||||
var normalized = query.Trim();
|
||||
if (normalized.Length > LegacyExpertSelectionService.MaximumQueryLength ||
|
||||
!IsSafeText(normalized))
|
||||
{
|
||||
throw new ArgumentException("The expert query exceeds 64 safe characters.", nameof(query));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool IsAsciiAlphaNumeric(string? value, int maximumLength) =>
|
||||
value is not null && value.Length is > 0 && value.Length <= maximumLength &&
|
||||
value.All(static character => char.IsAsciiLetterOrDigit(character));
|
||||
|
||||
private static bool IsCanonicalText(string? value, int maximumLength) =>
|
||||
value is not null && value.Length is > 0 && value.Length <= maximumLength &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) && IsSafeText(value);
|
||||
|
||||
private static bool IsSafeText(string value)
|
||||
{
|
||||
foreach (var character in value)
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(character);
|
||||
if (char.IsControl(character) || char.IsSurrogate(character) ||
|
||||
character == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or
|
||||
UnicodeCategory.LineSeparator or
|
||||
UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ResetPreview()
|
||||
{
|
||||
_previewItems = [];
|
||||
_previewTruncated = false;
|
||||
_previewStatus = LegacyExpertRequestStatus.Idle;
|
||||
_previewError = string.Empty;
|
||||
}
|
||||
|
||||
private void Touch() => _revision++;
|
||||
|
||||
private sealed record ExpertState(
|
||||
string SelectionId,
|
||||
ExpertSelectionIdentity Identity);
|
||||
}
|
||||
Reference in New Issue
Block a user