575 lines
19 KiB
C#
575 lines
19 KiB
C#
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 LegacyExpertWorkflowSnapshot RemoveSelectedResultPreservingPreview()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_selected is null)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
var removed = _searchResults.RemoveAll(row => row.Identity == _selected.Identity);
|
|
if (removed > 0)
|
|
{
|
|
// UC6 removed the visible expert row but intentionally retained m_code,
|
|
// m_name and the recommendation grid until the operator selected again.
|
|
Touch();
|
|
}
|
|
|
|
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 > MaximumAccessibleItems)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"An expert cut supports zero through 100 accessible recommendation rows.");
|
|
}
|
|
|
|
var pageCount = checked((itemCount + PageSize - 1) / PageSize);
|
|
if (pageCount > 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 <= 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);
|
|
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) ||
|
|
previous is not null && CompareIdentities(previous, item.Identity) > 0)
|
|
{
|
|
throw new ExpertSelectionDataException(
|
|
"Expert codes must be unique and identities 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 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))
|
|
{
|
|
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);
|
|
}
|