Complete legacy operator UI and playout migration

This commit is contained in:
2026-07-12 02:05:49 +09:00
parent d2e16bf0c2
commit ffb8f43c19
131 changed files with 48473 additions and 228 deletions

View File

@@ -0,0 +1,480 @@
#nullable enable
using System.Data;
using System.Globalization;
using System.Text.RegularExpressions;
namespace MMoneyCoderSharp.Data;
public sealed record ExpertCatalogRecommendation(
int PlayIndex,
string StockCode,
string StockName,
decimal BuyAmount);
public sealed record ExpertCatalogDefinition(
ExpertSelectionIdentity Identity,
IReadOnlyList<ExpertCatalogRecommendation> Recommendations);
public interface IExpertCatalogPersistenceService
{
bool CanMutate { get; }
Task<string> SuggestNextCodeAsync(CancellationToken cancellationToken = default);
Task<OperatorCatalogMutationReceipt> CreateAsync(
ExpertCatalogDefinition definition,
CancellationToken cancellationToken = default);
Task<OperatorCatalogMutationReceipt> ReplaceAsync(
ExpertSelectionIdentity expectedIdentity,
string newName,
IReadOnlyList<ExpertCatalogRecommendation> recommendations,
CancellationToken cancellationToken = default);
Task<OperatorCatalogMutationReceipt> DeleteAsync(
ExpertSelectionIdentity expectedIdentity,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Atomic migration of EList/UC6 expert CRUD on Oracle. Code and name are a
/// closed optimistic identity, while duplicate names and stale edits are checked
/// inside the same serializable transaction that replaces recommendation rows.
/// </summary>
public sealed partial class LegacyExpertCatalogPersistenceService : IExpertCatalogPersistenceService
{
public const int MaximumRecommendations = 100;
internal const string NextCodeQueryName = "EXPERT_NEXT_CODE";
private const int MaximumExpertNameLength = 128;
private const int MaximumStockNameLength = 200;
private const decimal MaximumWebSafeInteger = 9_007_199_254_740_991m;
private const string NextCodeSql = """
SELECT CASE
WHEN NVL(MAX(TO_NUMBER(TRIM(EP_CODE))), 0) < 9999
THEN LPAD(
TO_CHAR(NVL(MAX(TO_NUMBER(TRIM(EP_CODE))), 0) + 1),
4,
'0')
ELSE 'EXHAUSTED'
END EXPERT_CODE
FROM EXPERT_LIST
""";
private const string InsertExpertSql = """
INSERT INTO EXPERT_LIST(EP_CODE, EP_NAME)
SELECT :expert_code, :expert_name
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM EXPERT_LIST WHERE EP_CODE = :conflict_code)
AND NOT EXISTS (
SELECT 1 FROM EXPERT_LIST WHERE EP_NAME = :conflict_name)
""";
private const string UpdateExpertSql = """
UPDATE EXPERT_LIST target
SET EP_NAME = :new_name
WHERE target.EP_CODE = :expert_code
AND target.EP_NAME = :expected_name
AND NOT EXISTS (
SELECT 1
FROM EXPERT_LIST other_row
WHERE other_row.EP_NAME = :duplicate_name
AND other_row.EP_CODE <> :other_code)
""";
private const string DeleteRecommendationsSql =
"DELETE FROM RECOMMEND_LIST WHERE RC_CODE = :expert_code";
private const string InsertRecommendationSql = """
INSERT INTO RECOMMEND_LIST(
RC_CODE,
RC_STOCK_CODE,
RC_STOCK_NAME,
RC_BUY_AMOUNT,
PLAYINDEX)
VALUES (
:expert_code,
:stock_code,
:stock_name,
:buy_amount,
:play_index)
""";
private const string DeleteExpertSql = """
DELETE FROM EXPERT_LIST
WHERE EP_CODE = :expert_code
AND EP_NAME = :expected_name
""";
private readonly IDataQueryExecutor _queryExecutor;
private readonly IOperatorCatalogMutationExecutor? _mutationExecutor;
public LegacyExpertCatalogPersistenceService(IDataQueryExecutor queryExecutor)
: this(queryExecutor, null)
{
}
public LegacyExpertCatalogPersistenceService(
IDataQueryExecutor queryExecutor,
IOperatorCatalogMutationExecutor? mutationExecutor)
{
_queryExecutor = queryExecutor ?? throw new ArgumentNullException(nameof(queryExecutor));
_mutationExecutor = mutationExecutor;
}
public bool CanMutate => _mutationExecutor is not null;
public async Task<string> SuggestNextCodeAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var query = new DataQuerySpec(NextCodeSql);
query.ValidateFor(DataSourceKind.Oracle);
var table = await _queryExecutor.ExecuteAsync(
DataSourceKind.Oracle,
NextCodeQueryName,
query,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (table is null ||
table.Columns.Count != 1 ||
!string.Equals(table.Columns[0].ColumnName, "EXPERT_CODE", StringComparison.Ordinal) ||
table.Columns[0].DataType != typeof(string) ||
table.Rows.Count != 1 ||
table.Rows[0][0] is not string code ||
!ExpertCodePattern().IsMatch(code))
{
throw new ExpertSelectionDataException(
"Expert next-code data has an invalid schema, value, or exhausted code range.");
}
return code;
}
public Task<OperatorCatalogMutationReceipt> CreateAsync(
ExpertCatalogDefinition definition,
CancellationToken cancellationToken = default)
{
var canonical = ValidateDefinition(definition, nameof(definition));
var commands = new List<OperatorCatalogDbCommand>(
canonical.Recommendations.Count + 1)
{
Command(
OperatorCatalogDbCommandKind.InsertExpert,
InsertExpertSql,
OperatorCatalogAffectedRowsRule.ExactlyOne,
String("expert_code", canonical.Identity.ExpertCode),
String("expert_name", canonical.Identity.ExpertName),
String("conflict_code", canonical.Identity.ExpertCode),
String("conflict_name", canonical.Identity.ExpertName))
};
commands.AddRange(canonical.Recommendations.Select(recommendation =>
CreateRecommendationCommand(canonical.Identity.ExpertCode, recommendation)));
return ExecuteAsync(
new OperatorCatalogDbTransaction(
Guid.NewGuid(),
OperatorCatalogMutationKind.CreateExpert,
DataSourceKind.Oracle,
canonical.Identity.ExpertCode,
commands),
cancellationToken);
}
public Task<OperatorCatalogMutationReceipt> ReplaceAsync(
ExpertSelectionIdentity expectedIdentity,
string newName,
IReadOnlyList<ExpertCatalogRecommendation> recommendations,
CancellationToken cancellationToken = default)
{
var expected = ValidateIdentity(expectedIdentity, nameof(expectedIdentity));
var canonicalName = ValidateCanonicalText(
newName,
MaximumExpertNameLength,
nameof(newName));
var canonicalRecommendations = ValidateRecommendations(
recommendations,
nameof(recommendations));
var commands = new List<OperatorCatalogDbCommand>(
canonicalRecommendations.Count + 2)
{
Command(
OperatorCatalogDbCommandKind.UpdateExpert,
UpdateExpertSql,
OperatorCatalogAffectedRowsRule.ExactlyOne,
String("new_name", canonicalName),
String("expert_code", expected.ExpertCode),
String("expected_name", expected.ExpertName),
String("duplicate_name", canonicalName),
String("other_code", expected.ExpertCode)),
Command(
OperatorCatalogDbCommandKind.DeleteRecommendations,
DeleteRecommendationsSql,
OperatorCatalogAffectedRowsRule.AnyNonNegative,
String("expert_code", expected.ExpertCode))
};
commands.AddRange(canonicalRecommendations.Select(recommendation =>
CreateRecommendationCommand(expected.ExpertCode, recommendation)));
return ExecuteAsync(
new OperatorCatalogDbTransaction(
Guid.NewGuid(),
OperatorCatalogMutationKind.ReplaceExpert,
DataSourceKind.Oracle,
expected.ExpertCode,
commands),
cancellationToken);
}
public Task<OperatorCatalogMutationReceipt> DeleteAsync(
ExpertSelectionIdentity expectedIdentity,
CancellationToken cancellationToken = default)
{
var expected = ValidateIdentity(expectedIdentity, nameof(expectedIdentity));
return ExecuteAsync(
new OperatorCatalogDbTransaction(
Guid.NewGuid(),
OperatorCatalogMutationKind.DeleteExpert,
DataSourceKind.Oracle,
expected.ExpertCode,
[
Command(
OperatorCatalogDbCommandKind.DeleteRecommendations,
DeleteRecommendationsSql,
OperatorCatalogAffectedRowsRule.AnyNonNegative,
String("expert_code", expected.ExpertCode)),
Command(
OperatorCatalogDbCommandKind.DeleteExpert,
DeleteExpertSql,
OperatorCatalogAffectedRowsRule.ExactlyOne,
String("expert_code", expected.ExpertCode),
String("expected_name", expected.ExpertName))
]),
cancellationToken);
}
private async Task<OperatorCatalogMutationReceipt> ExecuteAsync(
OperatorCatalogDbTransaction transaction,
CancellationToken cancellationToken)
{
var executor = _mutationExecutor ?? throw new InvalidOperationException(
"Expert catalog persistence is configured for read-only access.");
cancellationToken.ThrowIfCancellationRequested();
OperatorCatalogDbTransactionResult result;
try
{
result = await executor.ExecuteTransactionAsync(transaction, cancellationToken)
.ConfigureAwait(false);
}
catch (OperatorCatalogMutationException)
{
throw;
}
catch (Exception exception)
{
throw new OperatorCatalogMutationException(
$"The {transaction.Kind} transaction outcome is unknown; do not retry automatically.",
outcomeUnknown: true,
exception);
}
if (result is null ||
result.OperationId != transaction.OperationId ||
result.AffectedRows is null ||
result.AffectedRows.Count != transaction.Commands.Count ||
result.AffectedRows.Any(static count => count < 0))
{
throw new OperatorCatalogMutationException(
$"The {transaction.Kind} transaction returned an ambiguous commit receipt; do not retry automatically.",
outcomeUnknown: true);
}
for (var index = 0; index < transaction.Commands.Count; index++)
{
if (transaction.Commands[index].AffectedRowsRule ==
OperatorCatalogAffectedRowsRule.ExactlyOne &&
result.AffectedRows[index] != 1)
{
throw new OperatorCatalogMutationException(
$"The {transaction.Kind} transaction returned an ambiguous affected-row receipt; do not retry automatically.",
outcomeUnknown: true);
}
}
return new OperatorCatalogMutationReceipt(
result.OperationId,
transaction.Kind,
result.CommittedAt);
}
private static OperatorCatalogDbCommand CreateRecommendationCommand(
string expertCode,
ExpertCatalogRecommendation recommendation) =>
Command(
OperatorCatalogDbCommandKind.InsertRecommendation,
InsertRecommendationSql,
OperatorCatalogAffectedRowsRule.ExactlyOne,
String("expert_code", expertCode),
String("stock_code", recommendation.StockCode),
String("stock_name", recommendation.StockName),
new OperatorCatalogDbParameter(
"buy_amount",
recommendation.BuyAmount,
DbType.Decimal),
new OperatorCatalogDbParameter(
"play_index",
recommendation.PlayIndex,
DbType.Int32));
private static ExpertCatalogDefinition ValidateDefinition(
ExpertCatalogDefinition definition,
string parameterName)
{
ArgumentNullException.ThrowIfNull(definition);
return new ExpertCatalogDefinition(
ValidateIdentity(definition.Identity, parameterName),
ValidateRecommendations(definition.Recommendations, parameterName));
}
private static ExpertSelectionIdentity ValidateIdentity(
ExpertSelectionIdentity identity,
string parameterName)
{
ArgumentNullException.ThrowIfNull(identity);
var code = ValidateCanonicalText(identity.ExpertCode, 4, parameterName);
if (!ExpertCodePattern().IsMatch(code))
{
throw new ArgumentException(
"An expert code must contain four digits.",
parameterName);
}
var name = ValidateCanonicalText(
identity.ExpertName,
MaximumExpertNameLength,
parameterName);
return new ExpertSelectionIdentity(code, name);
}
private static IReadOnlyList<ExpertCatalogRecommendation> ValidateRecommendations(
IReadOnlyList<ExpertCatalogRecommendation> recommendations,
string parameterName)
{
ArgumentNullException.ThrowIfNull(recommendations);
if (recommendations.Count > MaximumRecommendations)
{
throw new ArgumentOutOfRangeException(
parameterName,
$"An expert can contain at most {MaximumRecommendations} recommendations.");
}
var stockCodes = new HashSet<string>(StringComparer.Ordinal);
var stockNames = new HashSet<string>(StringComparer.Ordinal);
var canonical = new ExpertCatalogRecommendation[recommendations.Count];
for (var index = 0; index < recommendations.Count; index++)
{
var recommendation = recommendations[index] ?? throw new ArgumentException(
"An expert recommendation is missing.",
parameterName);
if (recommendation.PlayIndex != index)
{
throw new ArgumentException(
"Recommendation indexes must be contiguous and zero based.",
parameterName);
}
var stockCode = ValidateCanonicalText(
recommendation.StockCode,
32,
parameterName);
if (!StockCodePattern().IsMatch(stockCode))
{
throw new ArgumentException("A recommendation stock code is invalid.", parameterName);
}
var stockName = ValidateCanonicalText(
recommendation.StockName,
MaximumStockNameLength,
parameterName);
if (!stockCodes.Add(stockCode) || !stockNames.Add(stockName))
{
throw new ArgumentException(
"Recommendation stock codes and names must each be unique.",
parameterName);
}
var buyAmount = recommendation.BuyAmount;
if (buyAmount <= 0 || buyAmount > MaximumWebSafeInteger ||
decimal.Truncate(buyAmount) != buyAmount)
{
throw new ArgumentOutOfRangeException(
parameterName,
"A recommendation buy amount must be a positive whole web-safe integer.");
}
canonical[index] = new ExpertCatalogRecommendation(
index,
stockCode,
stockName,
buyAmount);
}
return Array.AsReadOnly(canonical);
}
private static string ValidateCanonicalText(
string value,
int maximumLength,
string parameterName)
{
ArgumentNullException.ThrowIfNull(value);
if (value.Length is < 1 || value.Length > maximumLength ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
!IsSafeText(value))
{
throw new ArgumentException("An expert catalog value is invalid.", parameterName);
}
return 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 static OperatorCatalogDbParameter String(string name, string value) =>
new(name, value, DbType.String);
private static OperatorCatalogDbCommand Command(
OperatorCatalogDbCommandKind kind,
string sql,
OperatorCatalogAffectedRowsRule rule,
params OperatorCatalogDbParameter[] parameters) =>
new(kind, sql, rule, parameters);
[GeneratedRegex("^[0-9]{4}$", RegexOptions.CultureInvariant)]
private static partial Regex ExpertCodePattern();
[GeneratedRegex("^[A-Za-z0-9]{1,32}$", RegexOptions.CultureInvariant)]
private static partial Regex StockCodePattern();
}

View File

@@ -0,0 +1,540 @@
#nullable enable
using System.Data;
using System.Globalization;
using System.Text.RegularExpressions;
namespace MMoneyCoderSharp.Data;
public sealed record ExpertSelectionIdentity(
string ExpertCode,
string ExpertName);
public sealed record ExpertSelectorItem(
ExpertSelectionIdentity Identity,
DataSourceKind Source);
public sealed record ExpertSearchResult(
string Query,
DateTimeOffset RetrievedAt,
IReadOnlyList<ExpertSelectorItem> Items,
bool IsTruncated);
public sealed record ExpertRecommendationPreview(
int PlayIndex,
string StockCode,
string StockName,
decimal BuyAmount);
public sealed record ExpertPreviewResult(
ExpertSelectionIdentity Identity,
DateTimeOffset RetrievedAt,
IReadOnlyList<ExpertRecommendationPreview> Items,
bool IsTruncated);
public interface IExpertSelectionService
{
Task<ExpertSearchResult> SearchAsync(
string query = "",
int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default);
Task<ExpertPreviewResult> PreviewAsync(
ExpertSelectionIdentity identity,
int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default);
}
public sealed class ExpertSelectionDataException : Exception
{
public ExpertSelectionDataException(string message)
: base(message)
{
}
}
/// <summary>
/// Read-only migration of UC6's Oracle expert list and recommendation preview.
/// Both expert code and name remain bound through preview and playout selection;
/// no CRUD statement or free-form table selection is exposed.
/// </summary>
public sealed partial class LegacyExpertSelectionService : IExpertSelectionService
{
public const int DefaultMaximumResults = 100;
public const int MaximumResults = 500;
public const int MaximumQueryLength = 64;
public const int DefaultMaximumPreviewItems = 100;
public const int MaximumPreviewItems = 100;
private const int MaximumExpertNameLength = 128;
private const int MaximumStockNameLength = 200;
private const int MaximumPlayIndex = 9_999;
private const decimal MaximumWebSafeInteger = 9_007_199_254_740_991m;
private const string SearchQueryName = "EXPERT_SEARCH";
private const string PreviewQueryName = "EXPERT_PREVIEW";
private static readonly string[] SearchColumns =
["EXPERT_NAME", "EXPERT_CODE"];
private static readonly string[] PreviewColumns =
[
"EXPERT_NAME",
"EXPERT_CODE",
"STOCK_CODE",
"STOCK_NAME",
"BUY_AMOUNT",
"PLAY_INDEX"
];
private const string SearchSql = """
SELECT EXPERT_NAME, EXPERT_CODE
FROM (
SELECT EP_NAME EXPERT_NAME,
EP_CODE EXPERT_CODE
FROM EXPERT_LIST
WHERE UPPER(EP_NAME) LIKE '%' || :search_term || '%' ESCAPE '!'
ORDER BY UPPER(EP_NAME), EP_CODE
)
WHERE ROWNUM <= :row_limit
""";
private const string PreviewSql = """
SELECT EXPERT_NAME, EXPERT_CODE,
STOCK_CODE, STOCK_NAME, BUY_AMOUNT, PLAY_INDEX
FROM (
SELECT e.EP_NAME EXPERT_NAME,
e.EP_CODE EXPERT_CODE,
r.RC_STOCK_CODE STOCK_CODE,
r.RC_STOCK_NAME STOCK_NAME,
TO_CHAR(
r.RC_BUY_AMOUNT,
'TM9',
'NLS_NUMERIC_CHARACTERS=''.,''') BUY_AMOUNT,
TO_CHAR(r.PLAYINDEX, 'FM999999990') PLAY_INDEX
FROM EXPERT_LIST e
LEFT JOIN RECOMMEND_LIST r ON e.EP_CODE = r.RC_CODE
WHERE e.EP_CODE = :expert_code
AND e.EP_NAME = :expert_name
ORDER BY r.PLAYINDEX
)
WHERE ROWNUM <= :row_limit
""";
private readonly IDataQueryExecutor _executor;
public LegacyExpertSelectionService(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<ExpertSearchResult> SearchAsync(
string query = "",
int maximumResults = DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var normalizedQuery = ValidateAndNormalizeQuery(query);
ValidateLimit(maximumResults, MaximumResults, nameof(maximumResults));
cancellationToken.ThrowIfCancellationRequested();
var rowLimit = checked(maximumResults + 1);
var spec = CreateSearchSpec(
EscapeLikePattern(normalizedQuery.ToUpperInvariant()),
rowLimit);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
SearchQueryName,
spec,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var matches = MapSearchRows(table, rowLimit);
var ordered = matches
.OrderBy(static item => item.Identity.ExpertName, StringComparer.Ordinal)
.ThenBy(static item => item.Identity.ExpertCode, StringComparer.Ordinal)
.Take(maximumResults)
.ToArray();
return new ExpertSearchResult(
normalizedQuery,
DateTimeOffset.Now,
ordered,
matches.Count > maximumResults);
}
public async Task<ExpertPreviewResult> PreviewAsync(
ExpertSelectionIdentity identity,
int maximumItems = DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default)
{
var canonicalIdentity = ValidateIdentity(identity);
ValidateLimit(maximumItems, MaximumPreviewItems, nameof(maximumItems));
cancellationToken.ThrowIfCancellationRequested();
var rowLimit = checked(maximumItems + 1);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
PreviewQueryName,
CreatePreviewSpec(canonicalIdentity, rowLimit),
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var items = MapPreviewRows(table, canonicalIdentity, rowLimit);
return new ExpertPreviewResult(
canonicalIdentity,
DateTimeOffset.Now,
items.Take(maximumItems).ToArray(),
items.Count > maximumItems);
}
private static IReadOnlyList<ExpertSelectorItem> MapSearchRows(
DataTable? table,
int rowLimit)
{
ValidateExactStringSchema(table, SearchColumns, rowLimit, SearchQueryName);
var names = new HashSet<string>(StringComparer.Ordinal);
var codes = new HashSet<string>(StringComparer.Ordinal);
var results = new List<ExpertSelectorItem>(table!.Rows.Count);
foreach (DataRow row in table.Rows)
{
var name = ReadCanonicalString(
row,
0,
MaximumExpertNameLength,
SearchQueryName);
var code = ReadExpertCode(row, 1, SearchQueryName);
// The paged scene originally resolves EP_CODE from EP_NAME. Both
// columns must therefore be unique before a selectable row is exposed.
if (!names.Add(name) || !codes.Add(code))
{
throw InvalidData(SearchQueryName, "duplicate expert name or code");
}
results.Add(new ExpertSelectorItem(
new ExpertSelectionIdentity(code, name),
DataSourceKind.Oracle));
}
return results;
}
private static IReadOnlyList<ExpertRecommendationPreview> MapPreviewRows(
DataTable? table,
ExpertSelectionIdentity identity,
int rowLimit)
{
ValidateExactStringSchema(table, PreviewColumns, rowLimit, PreviewQueryName);
if (table!.Rows.Count < 1)
{
throw InvalidData(PreviewQueryName, "missing selected expert");
}
var stockCodes = new HashSet<string>(StringComparer.Ordinal);
var stockNames = new HashSet<string>(StringComparer.Ordinal);
var playIndexes = new HashSet<int>();
var items = new List<ExpertRecommendationPreview>(table.Rows.Count);
var sawEmptyExpert = false;
foreach (DataRow row in table.Rows)
{
var expertName = ReadCanonicalString(
row,
0,
MaximumExpertNameLength,
PreviewQueryName);
var expertCode = ReadExpertCode(row, 1, PreviewQueryName);
if (!string.Equals(expertName, identity.ExpertName, StringComparison.Ordinal) ||
!string.Equals(expertCode, identity.ExpertCode, StringComparison.Ordinal))
{
throw InvalidData(PreviewQueryName, "selected expert identity");
}
var stockCodeValue = row[2];
var stockNameValue = row[3];
var buyAmountValue = row[4];
var playIndexValue = row[5];
var isEmptyExpert = stockCodeValue is DBNull &&
stockNameValue is DBNull &&
buyAmountValue is DBNull &&
playIndexValue is DBNull;
if (isEmptyExpert)
{
if (table.Rows.Count != 1)
{
throw InvalidData(PreviewQueryName, "empty recommendation sentinel");
}
sawEmptyExpert = true;
continue;
}
if (stockCodeValue is DBNull ||
stockNameValue is DBNull ||
buyAmountValue is DBNull ||
playIndexValue is DBNull)
{
throw InvalidData(PreviewQueryName, "partial recommendation identity");
}
var stockCode = ReadCanonicalString(row, 2, 32, PreviewQueryName);
if (!StockCodePattern().IsMatch(stockCode))
{
throw InvalidData(PreviewQueryName, "stock code");
}
var stockName = ReadCanonicalString(
row,
3,
MaximumStockNameLength,
PreviewQueryName);
var buyAmount = ReadPositiveDecimal(row, 4, PreviewQueryName);
var playIndex = ReadPlayIndex(row, 5, PreviewQueryName);
if (!stockCodes.Add(stockCode) ||
!stockNames.Add(stockName) ||
!playIndexes.Add(playIndex))
{
throw InvalidData(
PreviewQueryName,
"duplicate stock code, stock name, or play index");
}
items.Add(new ExpertRecommendationPreview(
playIndex,
stockCode,
stockName,
buyAmount));
}
if (sawEmptyExpert)
{
return Array.Empty<ExpertRecommendationPreview>();
}
for (var index = 1; index < items.Count; index++)
{
if (items[index - 1].PlayIndex >= items[index].PlayIndex)
{
throw InvalidData(PreviewQueryName, "recommendation order");
}
}
return items;
}
private static DataQuerySpec CreateSearchSpec(string escapedQuery, int rowLimit)
{
var spec = new DataQuerySpec(
SearchSql,
[
new DataQueryParameter("search_term", escapedQuery, DbType.String),
new DataQueryParameter("row_limit", rowLimit, DbType.Int32)
]);
spec.ValidateFor(DataSourceKind.Oracle);
return spec;
}
private static DataQuerySpec CreatePreviewSpec(
ExpertSelectionIdentity identity,
int rowLimit)
{
var spec = new DataQuerySpec(
PreviewSql,
[
new DataQueryParameter("expert_code", identity.ExpertCode, DbType.String),
new DataQueryParameter("expert_name", identity.ExpertName, DbType.String),
new DataQueryParameter("row_limit", rowLimit, DbType.Int32)
]);
spec.ValidateFor(DataSourceKind.Oracle);
return spec;
}
private static ExpertSelectionIdentity ValidateIdentity(ExpertSelectionIdentity identity)
{
ArgumentNullException.ThrowIfNull(identity);
var code = ValidateCanonicalInput(identity.ExpertCode, 4, nameof(identity));
var name = ValidateCanonicalInput(
identity.ExpertName,
MaximumExpertNameLength,
nameof(identity));
if (!ExpertCodePattern().IsMatch(code))
{
throw new ArgumentException("The expert selection code is invalid.", nameof(identity));
}
return identity with { ExpertCode = code, ExpertName = name };
}
private static string ValidateAndNormalizeQuery(string query)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsSafeText(query))
{
throw new ArgumentException("The expert search query is invalid.", nameof(query));
}
var normalized = query.Trim();
if (normalized.Length > MaximumQueryLength || !IsSafeText(normalized))
{
throw new ArgumentException(
$"An expert search query must contain at most {MaximumQueryLength} visible characters.",
nameof(query));
}
return normalized;
}
private static string ValidateCanonicalInput(
string value,
int maximumLength,
string parameterName)
{
ArgumentNullException.ThrowIfNull(value);
if (value.Length is < 1 || value.Length > maximumLength ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
!IsSafeText(value))
{
throw new ArgumentException("The expert selection value is invalid.", parameterName);
}
return value;
}
private static void ValidateLimit(int value, int maximum, string parameterName)
{
if (value is < 1 || value > maximum)
{
throw new ArgumentOutOfRangeException(
parameterName,
value,
$"The requested limit must be between 1 and {maximum}.");
}
}
private static void ValidateExactStringSchema(
DataTable? table,
IReadOnlyList<string> columns,
int rowLimit,
string queryName)
{
if (table is null || table.Columns.Count != columns.Count || table.Rows.Count > rowLimit)
{
throw InvalidData(queryName, "schema or row bound");
}
for (var index = 0; index < columns.Count; index++)
{
if (!string.Equals(
table.Columns[index].ColumnName,
columns[index],
StringComparison.Ordinal) ||
table.Columns[index].DataType != typeof(string))
{
throw InvalidData(queryName, "schema");
}
}
}
private static string ReadCanonicalString(
DataRow row,
int ordinal,
int maximumLength,
string queryName)
{
if (row[ordinal] is not string value ||
value.Length is < 1 || value.Length > maximumLength ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
!IsSafeText(value))
{
throw InvalidData(queryName, "value");
}
return value;
}
private static string ReadExpertCode(DataRow row, int ordinal, string queryName)
{
var code = ReadCanonicalString(row, ordinal, 4, queryName);
if (!ExpertCodePattern().IsMatch(code))
{
throw InvalidData(queryName, "expert code");
}
return code;
}
private static decimal ReadPositiveDecimal(DataRow row, int ordinal, string queryName)
{
var raw = ReadCanonicalString(row, ordinal, 64, queryName);
if (!decimal.TryParse(
raw,
NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint,
CultureInfo.InvariantCulture,
out var value) ||
value <= 0 ||
value > MaximumWebSafeInteger ||
decimal.Truncate(value) != value ||
!string.Equals(
raw,
value.ToString(CultureInfo.InvariantCulture),
StringComparison.Ordinal))
{
throw InvalidData(queryName, "buy amount");
}
return value;
}
private static int ReadPlayIndex(DataRow row, int ordinal, string queryName)
{
var raw = ReadCanonicalString(row, ordinal, 4, queryName);
if (!int.TryParse(
raw,
NumberStyles.None,
CultureInfo.InvariantCulture,
out var value) ||
value is < 0 or > MaximumPlayIndex ||
!string.Equals(
raw,
value.ToString(CultureInfo.InvariantCulture),
StringComparison.Ordinal))
{
throw InvalidData(queryName, "play index");
}
return value;
}
private static string EscapeLikePattern(string value) => value
.Replace("!", "!!", StringComparison.Ordinal)
.Replace("%", "!%", StringComparison.Ordinal)
.Replace("_", "!_", StringComparison.Ordinal);
private static bool IsSafeText(string value)
{
for (var index = 0; index < value.Length; index++)
{
var character = value[index];
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 static ExpertSelectionDataException InvalidData(string queryName, string detail) =>
new($"Expert selection data from {queryName} contains an invalid {detail}.");
[GeneratedRegex("^[0-9]{4}$", RegexOptions.CultureInvariant)]
private static partial Regex ExpertCodePattern();
[GeneratedRegex("^[A-Za-z0-9]{1,32}$", RegexOptions.CultureInvariant)]
private static partial Regex StockCodePattern();
}

View File

@@ -0,0 +1,141 @@
#nullable enable
using System.Data;
using System.Text.RegularExpressions;
namespace MMoneyCoderSharp.Data;
public enum IndustryMarket
{
Kospi,
Kosdaq
}
public sealed record IndustrySelection(
IndustryMarket Market,
string IndustryName,
string IndustryCode);
public interface IIndustrySelectionService
{
Task<IReadOnlyList<IndustrySelection>> GetAsync(
IndustryMarket market,
CancellationToken cancellationToken = default);
}
public sealed class IndustrySelectionDataException : Exception
{
public IndustrySelectionDataException(string message) : base(message)
{
}
}
/// <summary>
/// Reads the two industry selectors used by legacy UC2. The Web layer receives only
/// normalized names and codes; it never chooses a table or supplies SQL text.
/// </summary>
public sealed partial class LegacyIndustrySelectionService : IIndustrySelectionService
{
public const int MaximumResults = 500;
public const int MaximumNameLength = 128;
private readonly IDataQueryExecutor _executor;
public LegacyIndustrySelectionService(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<IReadOnlyList<IndustrySelection>> GetAsync(
IndustryMarket market,
CancellationToken cancellationToken = default)
{
var query = QueryFor(market);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
query.TableName,
query.Spec,
cancellationToken).ConfigureAwait(false);
return Map(table, market);
}
private static IndustryQuery QueryFor(IndustryMarket market) => market switch
{
IndustryMarket.Kospi => new IndustryQuery(
"OPERATOR_KOSPI_INDUSTRIES",
new DataQuerySpec(
"SELECT F_PART_NAME INDUSTRY_NAME, F_PART_CODE INDUSTRY_CODE " +
"FROM T_PART WHERE F_PART_CODE <> '001' ORDER BY F_PART_NAME")),
IndustryMarket.Kosdaq => new IndustryQuery(
"OPERATOR_KOSDAQ_INDUSTRIES",
new DataQuerySpec(
"SELECT DISTINCT A.F_PART_NAME INDUSTRY_NAME, A.F_PART_CODE INDUSTRY_CODE " +
"FROM T_KOSDAQ_PART A INNER JOIN T_KOSDAQ_INDEX B " +
"ON A.F_PART_CODE = B.F_PART_CODE " +
"WHERE A.F_PART_CODE <> '001' ORDER BY A.F_PART_CODE")),
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.")
};
private static IReadOnlyList<IndustrySelection> Map(DataTable? table, IndustryMarket market)
{
if (table is null || table.Columns.Count != 2 ||
!HasColumn(table, "INDUSTRY_NAME") || !HasColumn(table, "INDUSTRY_CODE") ||
table.Rows.Count > MaximumResults)
{
throw new IndustrySelectionDataException("The industry selector returned an invalid schema.");
}
var results = new List<IndustrySelection>(table.Rows.Count);
var names = new HashSet<string>(StringComparer.Ordinal);
var codes = new HashSet<string>(StringComparer.Ordinal);
foreach (DataRow row in table.Rows)
{
var name = ReadRequiredText(row, "INDUSTRY_NAME", MaximumNameLength);
var code = ReadRequiredText(row, "INDUSTRY_CODE", 32);
if (!IndustryCodePattern().IsMatch(code))
{
throw new IndustrySelectionDataException("The industry selector returned an invalid code.");
}
// Every downstream scene query resolves an industry by name. Treat both
// columns as unique identities so a selector can never represent an
// ambiguous name/code pair, even if the returned tuples are distinct.
if (!names.Add(name) || !codes.Add(code))
{
throw new IndustrySelectionDataException(
"The industry selector returned an ambiguous duplicate name or code.");
}
results.Add(new IndustrySelection(market, name, code));
}
return Array.AsReadOnly(results.ToArray());
}
private static bool HasColumn(DataTable table, string name) =>
table.Columns.Cast<DataColumn>().Any(column =>
string.Equals(column.ColumnName, name, StringComparison.OrdinalIgnoreCase));
private static string ReadRequiredText(DataRow row, string column, int maximumLength)
{
var value = row[column];
if (value is null or DBNull)
{
throw new IndustrySelectionDataException("The industry selector returned a missing value.");
}
var text = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)?.Trim();
if (string.IsNullOrWhiteSpace(text) || text.Length > maximumLength ||
text.Any(character => char.IsControl(character)))
{
throw new IndustrySelectionDataException("The industry selector returned an invalid value.");
}
return text;
}
[GeneratedRegex("^[A-Za-z0-9]{1,32}$", RegexOptions.CultureInvariant)]
private static partial Regex IndustryCodePattern();
private sealed record IndustryQuery(string TableName, DataQuerySpec Spec);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
#nullable enable
using System.Collections.ObjectModel;
using System.Data;
namespace MMoneyCoderSharp.Data;
public enum OperatorCatalogMutationKind
{
CreateTheme,
ReplaceTheme,
DeleteTheme,
CreateExpert,
ReplaceExpert,
DeleteExpert
}
public enum OperatorCatalogDbCommandKind
{
InsertTheme,
UpdateTheme,
DeleteThemeItems,
InsertThemeItem,
DeleteTheme,
InsertExpert,
UpdateExpert,
DeleteRecommendations,
InsertRecommendation,
DeleteExpert
}
/// <summary>
/// The mutation executor must evaluate this expectation immediately after the
/// command, before it executes the next command or commits the transaction.
/// </summary>
public enum OperatorCatalogAffectedRowsRule
{
AnyNonNegative,
ExactlyOne
}
/// <summary>
/// One bound input value in the closed theme/expert mutation command set.
/// Values are deliberately omitted from diagnostics.
/// </summary>
public sealed class OperatorCatalogDbParameter
{
internal OperatorCatalogDbParameter(string name, object? value, DbType dbType)
{
if (!DataQueryParameter.IsValidName(name) || !Enum.IsDefined(dbType))
{
throw new ArgumentException("An operator-catalog parameter is invalid.");
}
Name = name;
Value = value;
DbType = dbType;
}
public string Name { get; }
public object? Value { get; }
public DbType DbType { get; }
public override string ToString() => "Operator-catalog database parameter";
}
/// <summary>
/// A command from a closed, provider-specific SQL set. Arbitrary SQL cannot be
/// supplied by WebView or application callers because construction is internal
/// to the Core assembly.
/// </summary>
public sealed class OperatorCatalogDbCommand
{
internal OperatorCatalogDbCommand(
OperatorCatalogDbCommandKind kind,
string sql,
OperatorCatalogAffectedRowsRule affectedRowsRule,
IEnumerable<OperatorCatalogDbParameter> parameters)
{
Kind = kind;
Sql = sql;
AffectedRowsRule = affectedRowsRule;
Parameters = new ReadOnlyCollection<OperatorCatalogDbParameter>(parameters.ToArray());
}
public OperatorCatalogDbCommandKind Kind { get; }
public string Sql { get; }
public OperatorCatalogAffectedRowsRule AffectedRowsRule { get; }
public IReadOnlyList<OperatorCatalogDbParameter> Parameters { get; }
public override string ToString() => $"Operator-catalog {Kind} command";
}
/// <summary>
/// An ordered mutation that must execute exactly once on one connection under
/// one serializable transaction. Executors must not retry any command or batch.
/// </summary>
public sealed class OperatorCatalogDbTransaction
{
internal OperatorCatalogDbTransaction(
Guid operationId,
OperatorCatalogMutationKind kind,
DataSourceKind source,
string identityCode,
IEnumerable<OperatorCatalogDbCommand> commands)
{
OperationId = operationId;
Kind = kind;
Source = source;
IdentityCode = identityCode;
Commands = new ReadOnlyCollection<OperatorCatalogDbCommand>(commands.ToArray());
}
public Guid OperationId { get; }
public OperatorCatalogMutationKind Kind { get; }
public DataSourceKind Source { get; }
public string IdentityCode { get; }
public IReadOnlyList<OperatorCatalogDbCommand> Commands { get; }
public override string ToString() => $"Operator-catalog {Kind} transaction";
}
public sealed record OperatorCatalogDbTransactionResult(
Guid OperationId,
IReadOnlyList<int> AffectedRows,
DateTimeOffset CommittedAt);
public sealed record OperatorCatalogMutationReceipt(
Guid OperationId,
OperatorCatalogMutationKind Kind,
DateTimeOffset CommittedAt);
/// <summary>
/// Infrastructure seam for state-changing theme/expert work. Returning means
/// commit completed. Implementations must execute once, check every affected-row
/// rule before continuing, and never apply a transient retry policy.
/// </summary>
public interface IOperatorCatalogMutationExecutor
{
Task<OperatorCatalogDbTransactionResult> ExecuteTransactionAsync(
OperatorCatalogDbTransaction transaction,
CancellationToken cancellationToken = default);
}
public class OperatorCatalogMutationException : Exception
{
public OperatorCatalogMutationException(
string message,
bool outcomeUnknown,
Exception? innerException = null)
: base(message, innerException)
{
OutcomeUnknown = outcomeUnknown;
}
/// <summary>
/// True means the operator must reconcile the database manually. The
/// application must never retry that operation automatically.
/// </summary>
public bool OutcomeUnknown { get; }
}
/// <summary>
/// A serializable transaction was rolled back because its bound identity,
/// uniqueness precondition, or affected-row expectation no longer matched.
/// The caller must refresh and make a new explicit edit decision.
/// </summary>
public sealed class OperatorCatalogConflictException : OperatorCatalogMutationException
{
public OperatorCatalogConflictException(
OperatorCatalogMutationKind kind,
OperatorCatalogDbCommandKind commandKind,
Exception? innerException = null)
: base(
$"The {kind} operation conflicted with current catalog data and was rolled back; refresh before trying a new operation.",
outcomeUnknown: false,
innerException)
{
Kind = kind;
CommandKind = commandKind;
}
public OperatorCatalogMutationKind Kind { get; }
public OperatorCatalogDbCommandKind CommandKind { get; }
}

View File

@@ -0,0 +1,170 @@
#nullable enable
using System.Data;
namespace MMoneyCoderSharp.Data;
public sealed record OperatorCatalogSchemaValidationResult(
DateTimeOffset ValidatedAt,
IReadOnlyList<DataSourceKind> ValidatedSources);
public interface IOperatorCatalogSchemaValidationService
{
Task<OperatorCatalogSchemaValidationResult> ValidateAsync(
CancellationToken cancellationToken = default);
}
/// <summary>
/// Read-only schema preflight for the four original CRUD tables. Each query is
/// deliberately constrained by WHERE 1 = 0: providers parse and describe all
/// referenced tables/columns, but no catalog row is read or changed.
/// </summary>
public sealed class LegacyOperatorCatalogSchemaValidationService :
IOperatorCatalogSchemaValidationService
{
internal const string OracleQueryName = "OPERATOR_CATALOG_SCHEMA_ORACLE";
internal const string MariaDbQueryName = "OPERATOR_CATALOG_SCHEMA_MARIADB";
internal const string OracleSql = """
SELECT l.SB_CODE THEME_CODE,
l.SB_TITLE THEME_TITLE,
l.SB_MARKET THEME_MARKET,
i.SB_M_CODE ITEM_THEME_CODE,
i.SB_I_CODE ITEM_CODE,
i.SB_I_NAME ITEM_NAME,
i.SB_I_INDEX ITEM_INDEX,
e.EP_CODE EXPERT_CODE,
e.EP_NAME EXPERT_NAME,
r.RC_CODE RECOMMEND_EXPERT_CODE,
r.RC_STOCK_CODE STOCK_CODE,
r.RC_STOCK_NAME STOCK_NAME,
r.RC_BUY_AMOUNT BUY_AMOUNT,
r.PLAYINDEX PLAY_INDEX
FROM SB_LIST l
LEFT JOIN SB_ITEM i ON 1 = 0
LEFT JOIN EXPERT_LIST e ON 1 = 0
LEFT JOIN RECOMMEND_LIST r ON 1 = 0
WHERE 1 = 0
""";
internal const string MariaDbSql = """
SELECT l.SB_CODE THEME_CODE,
l.SB_TITLE THEME_TITLE,
l.SB_MARKET THEME_MARKET,
i.SB_M_CODE ITEM_THEME_CODE,
i.SB_I_CODE ITEM_CODE,
i.SB_I_NAME ITEM_NAME,
i.SB_I_INDEX ITEM_INDEX
FROM SB_LIST l
LEFT JOIN SB_ITEM i ON 1 = 0
WHERE 1 = 0
""";
private static readonly string[] OracleColumns =
[
"THEME_CODE",
"THEME_TITLE",
"THEME_MARKET",
"ITEM_THEME_CODE",
"ITEM_CODE",
"ITEM_NAME",
"ITEM_INDEX",
"EXPERT_CODE",
"EXPERT_NAME",
"RECOMMEND_EXPERT_CODE",
"STOCK_CODE",
"STOCK_NAME",
"BUY_AMOUNT",
"PLAY_INDEX"
];
private static readonly string[] MariaDbColumns =
[
"THEME_CODE",
"THEME_TITLE",
"THEME_MARKET",
"ITEM_THEME_CODE",
"ITEM_CODE",
"ITEM_NAME",
"ITEM_INDEX"
];
private readonly IDataQueryExecutor _executor;
public LegacyOperatorCatalogSchemaValidationService(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<OperatorCatalogSchemaValidationResult> ValidateAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
await ValidateSourceAsync(
DataSourceKind.Oracle,
OracleQueryName,
OracleSql,
OracleColumns,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
await ValidateSourceAsync(
DataSourceKind.MariaDb,
MariaDbQueryName,
MariaDbSql,
MariaDbColumns,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return new OperatorCatalogSchemaValidationResult(
DateTimeOffset.Now,
[DataSourceKind.Oracle, DataSourceKind.MariaDb]);
}
private async Task ValidateSourceAsync(
DataSourceKind source,
string queryName,
string sql,
IReadOnlyList<string> expectedColumns,
CancellationToken cancellationToken)
{
var spec = new DataQuerySpec(sql);
spec.ValidateFor(source);
var table = await _executor.ExecuteAsync(
source,
queryName,
spec,
cancellationToken).ConfigureAwait(false);
if (table is null || table.Rows.Count != 0 ||
table.Columns.Count != expectedColumns.Count)
{
throw new OperatorCatalogSchemaException(
source,
"The read-only schema preflight returned an unexpected shape.");
}
for (var index = 0; index < expectedColumns.Count; index++)
{
if (!string.Equals(
table.Columns[index].ColumnName,
expectedColumns[index],
StringComparison.Ordinal))
{
throw new OperatorCatalogSchemaException(
source,
"The read-only schema preflight returned an unexpected column.");
}
}
}
}
public sealed class OperatorCatalogSchemaException : Exception
{
public OperatorCatalogSchemaException(DataSourceKind source, string message)
: base($"{source} operator-catalog schema validation failed. {message}")
{
DataSource = source;
}
public DataSourceKind DataSource { get; }
}

View File

@@ -0,0 +1,242 @@
#nullable enable
using System.Data;
using System.Globalization;
namespace MMoneyCoderSharp.Data;
/// <summary>
/// Exact US foreign-industry/index identity from T_WORLD_IX_EQ_MASTER.
/// KoreanName is F_KNAM (the one-column lookup key), InputName is
/// F_INPUT_NAME (the candle lookup key), and Symbol is F_SYMB.
/// </summary>
public sealed record OverseasIndustryIndexItem(
string KoreanName,
string InputName,
string Symbol);
public sealed record OverseasIndustryIndexSearchResult(
string Query,
DateTimeOffset RetrievedAt,
IReadOnlyList<OverseasIndustryIndexItem> Items,
bool IsTruncated);
public interface IOverseasIndustryIndexSearchService
{
Task<OverseasIndustryIndexSearchResult> SearchAsync(
string query,
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default);
}
public sealed class OverseasIndustryIndexSearchDataException : Exception
{
public OverseasIndustryIndexSearchDataException(string message)
: base(message)
{
}
}
/// <summary>
/// Parameterized, read-only migration of UC5.upjong_search. Blank input keeps
/// the original initial-list behavior while all non-blank LIKE metacharacters
/// remain data through an escaped Oracle bind value.
/// </summary>
public sealed class LegacyOverseasIndustryIndexSearchService
: IOverseasIndustryIndexSearchService
{
public const int DefaultMaximumResults = 100;
public const int MaximumResults = 500;
public const int MaximumQueryLength = 64;
private const int MaximumKoreanNameLength = 200;
private const int MaximumInputNameLength = 200;
private const int MaximumSymbolLength = 64;
private const string QueryName = "OVERSEAS_INDUSTRY_INDEX_SEARCH";
private static readonly string[] ResultColumns =
["F_KNAM", "F_INPUT_NAME", "F_SYMB"];
private const string SearchSql = """
SELECT F_KNAM, F_INPUT_NAME, F_SYMB
FROM (
SELECT F_KNAM, F_INPUT_NAME, F_SYMB
FROM (
SELECT F_KNAM, F_INPUT_NAME, F_SYMB,
COUNT(*) OVER (PARTITION BY F_KNAM) AS KNAM_COUNT,
COUNT(*) OVER (PARTITION BY F_INPUT_NAME) AS INPUT_NAME_COUNT,
COUNT(*) OVER (PARTITION BY F_SYMB) AS SYMBOL_COUNT
FROM T_WORLD_IX_EQ_MASTER
WHERE F_FDTC = '0'
AND F_NATC = 'US'
)
WHERE KNAM_COUNT = 1
AND INPUT_NAME_COUNT = 1
AND SYMBOL_COUNT = 1
AND UPPER(F_KNAM) LIKE '%' || :search_term || '%' ESCAPE '!'
ORDER BY UPPER(F_KNAM), F_INPUT_NAME, F_SYMB
)
WHERE ROWNUM <= :row_limit
""";
private readonly IDataQueryExecutor _executor;
public LegacyOverseasIndustryIndexSearchService(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<OverseasIndustryIndexSearchResult> SearchAsync(
string query,
int maximumResults = DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var normalizedQuery = ValidateAndNormalizeQuery(query);
if (maximumResults is < 1 or > MaximumResults)
{
throw new ArgumentOutOfRangeException(
nameof(maximumResults),
$"The requested result limit must be between 1 and {MaximumResults}.");
}
cancellationToken.ThrowIfCancellationRequested();
var rowLimit = checked(maximumResults + 1);
var spec = CreateQuerySpec(
EscapeLikePattern(normalizedQuery.ToUpperInvariant()),
rowLimit);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
QueryName,
spec,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var matches = ValidateRows(table, rowLimit);
return new OverseasIndustryIndexSearchResult(
normalizedQuery,
DateTimeOffset.Now,
matches.Take(maximumResults).ToArray(),
matches.Count > maximumResults);
}
private static DataQuerySpec CreateQuerySpec(string escapedQuery, int rowLimit)
{
var spec = new DataQuerySpec(
SearchSql,
[
new DataQueryParameter("search_term", escapedQuery, DbType.String),
new DataQueryParameter("row_limit", rowLimit, DbType.Int32)
]);
spec.ValidateFor(DataSourceKind.Oracle);
return spec;
}
private static string ValidateAndNormalizeQuery(string query)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsSafeText(query))
{
throw new ArgumentException("The overseas selector query is invalid.", nameof(query));
}
var normalized = query.Trim();
if (normalized.Length > MaximumQueryLength || !IsSafeText(normalized))
{
throw new ArgumentException(
$"An overseas selector query must contain at most {MaximumQueryLength} visible characters.",
nameof(query));
}
return normalized;
}
private static IReadOnlyList<OverseasIndustryIndexItem> ValidateRows(
DataTable? table,
int rowLimit)
{
if (table is null ||
table.Columns.Count != ResultColumns.Length ||
table.Rows.Count > rowLimit)
{
throw InvalidData("schema or row bound");
}
for (var index = 0; index < ResultColumns.Length; index++)
{
var column = table.Columns[index];
if (!string.Equals(
column.ColumnName,
ResultColumns[index],
StringComparison.Ordinal) ||
column.DataType != typeof(string))
{
throw InvalidData("schema");
}
}
var koreanNames = new HashSet<string>(StringComparer.Ordinal);
var inputNames = new HashSet<string>(StringComparer.Ordinal);
var symbols = new HashSet<string>(StringComparer.Ordinal);
var results = new List<OverseasIndustryIndexItem>(table.Rows.Count);
foreach (DataRow row in table.Rows)
{
var koreanName = ReadCanonicalValue(row, 0, MaximumKoreanNameLength);
var inputName = ReadCanonicalValue(row, 1, MaximumInputNameLength);
var symbol = ReadCanonicalValue(row, 2, MaximumSymbolLength);
// Current one-column, candle, and quote loaders use F_KNAM,
// F_INPUT_NAME, and F_SYMB respectively. A duplicate in any key can
// resolve a different master row from the operator's chosen entry.
if (!koreanNames.Add(koreanName) ||
!inputNames.Add(inputName) ||
!symbols.Add(symbol))
{
throw InvalidData("ambiguous duplicate downstream lookup key");
}
results.Add(new OverseasIndustryIndexItem(koreanName, inputName, symbol));
}
return results;
}
private static string ReadCanonicalValue(DataRow row, int ordinal, int maximumLength)
{
if (row[ordinal] is not string value ||
value.Length is < 1 || value.Length > maximumLength ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
!IsSafeText(value))
{
throw InvalidData("value");
}
return value;
}
private static string EscapeLikePattern(string value) => value
.Replace("!", "!!", StringComparison.Ordinal)
.Replace("%", "!%", StringComparison.Ordinal)
.Replace("_", "!_", StringComparison.Ordinal);
private static bool IsSafeText(string value)
{
for (var index = 0; index < value.Length; index++)
{
var character = value[index];
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 static OverseasIndustryIndexSearchDataException InvalidData(string detail) =>
new($"Overseas industry/index data from {QueryName} contains an invalid {detail}.");
}

View File

@@ -0,0 +1,281 @@
#nullable enable
using System.Data;
using System.Globalization;
namespace MMoneyCoderSharp.Data;
public enum StockMarket
{
Kospi,
Kosdaq,
NxtKospi,
NxtKosdaq
}
public sealed record StockSearchItem(
StockMarket Market,
DataSourceKind Source,
string Name,
string Code);
public sealed record StockSearchResult(
string Query,
DateTimeOffset RetrievedAt,
IReadOnlyList<StockSearchItem> Items,
bool IsTruncated);
public interface IStockSearchService
{
Task<StockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default);
}
public sealed class StockSearchDataException : Exception
{
public StockSearchDataException(string message)
: base(message)
{
}
}
/// <summary>
/// Searches the four domestic stock masters used by the legacy operator lookup.
/// Input is always bound, LIKE metacharacters are escaped, and every provider
/// result must match the two-column contract before any value reaches the UI.
/// </summary>
public sealed class LegacyStockSearchService : IStockSearchService
{
public const int DefaultMaximumResults = 100;
public const int MaximumResults = 500;
public const int MaximumQueryLength = 64;
private const int MaximumStockNameLength = 200;
private const int MaximumStockCodeLength = 32;
private const string StockNameColumn = "STOCK_NAME";
private const string StockCodeColumn = "STOCK_CODE";
private readonly IDataQueryExecutor _executor;
public LegacyStockSearchService(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<StockSearchResult> SearchAsync(
string query,
int maximumResults = DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var normalizedQuery = ValidateAndNormalizeQuery(query);
if (maximumResults is < 1 or > MaximumResults)
{
throw new ArgumentOutOfRangeException(
nameof(maximumResults),
$"The requested result limit must be between 1 and {MaximumResults}.");
}
cancellationToken.ThrowIfCancellationRequested();
var escapedQuery = EscapeLikePattern(normalizedQuery.ToUpperInvariant());
var perMarketLimit = checked(maximumResults + 1);
var matches = new List<StockSearchItem>(perMarketLimit * StockSearchQueries.All.Count);
// Keep provider load predictable and cancellation responsive. The shared
// application database gate grants this whole operation one read slot.
foreach (var profile in StockSearchQueries.All)
{
cancellationToken.ThrowIfCancellationRequested();
var spec = profile.CreateSpec(escapedQuery, perMarketLimit);
var table = await _executor
.ExecuteAsync(profile.Source, profile.TableName, spec, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
AppendValidatedRows(table, profile, matches);
}
var ordered = matches
.OrderBy(static item => item.Market)
.ThenBy(static item => item.Name, StringComparer.Ordinal)
.ThenBy(static item => item.Code, StringComparer.Ordinal)
.Take(maximumResults)
.ToArray();
return new StockSearchResult(
normalizedQuery,
DateTimeOffset.Now,
ordered,
matches.Count > maximumResults);
}
private static string ValidateAndNormalizeQuery(string query)
{
ArgumentNullException.ThrowIfNull(query);
var normalized = query.Trim();
if (!IsSafeText(query) ||
normalized.Length is < 1 or > MaximumQueryLength ||
!IsSafeText(normalized))
{
throw new ArgumentException(
$"A stock search query must contain 1 to {MaximumQueryLength} visible characters.",
nameof(query));
}
return normalized;
}
private static string EscapeLikePattern(string value) => value
.Replace("!", "!!", StringComparison.Ordinal)
.Replace("%", "!%", StringComparison.Ordinal)
.Replace("_", "!_", StringComparison.Ordinal);
private static void AppendValidatedRows(
DataTable? table,
StockSearchQueryProfile profile,
ICollection<StockSearchItem> destination)
{
if (table is null ||
table.Columns.Count != 2 ||
!HasExactStringColumn(table.Columns[0], StockNameColumn) ||
!HasExactStringColumn(table.Columns[1], StockCodeColumn))
{
throw InvalidSchema(profile.TableName);
}
foreach (DataRow row in table.Rows)
{
if (row[0] is not string rawName || row[1] is not string rawCode)
{
throw InvalidSchema(profile.TableName);
}
var name = rawName.Trim();
var code = rawCode.Trim();
if (!IsSafeDatabaseValue(name, MaximumStockNameLength) ||
!IsSafeDatabaseValue(code, MaximumStockCodeLength))
{
throw new StockSearchDataException(
$"Stock search data from {profile.TableName} contains an invalid value.");
}
destination.Add(new StockSearchItem(profile.Market, profile.Source, name, code));
}
}
private static bool HasExactStringColumn(DataColumn column, string name) =>
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string);
private static bool IsSafeDatabaseValue(string value, int maximumLength) =>
value.Length is > 0 && value.Length <= maximumLength && IsSafeText(value);
private static bool IsSafeText(string value)
{
for (var index = 0; index < value.Length; index++)
{
var character = value[index];
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 static StockSearchDataException InvalidSchema(string tableName) =>
new($"Stock search data from {tableName} does not match the required schema.");
}
internal sealed record StockSearchQueryProfile(
StockMarket Market,
DataSourceKind Source,
string TableName,
string Sql)
{
internal DataQuerySpec CreateSpec(string escapedQuery, int rowLimit)
{
var queryParameter = new DataQueryParameter(
"search_term",
escapedQuery,
DbType.String);
var limitParameter = new DataQueryParameter(
"row_limit",
rowLimit,
DbType.Int32);
var spec = new DataQuerySpec(Sql, [queryParameter, limitParameter]);
spec.ValidateFor(Source);
return spec;
}
}
internal static class StockSearchQueries
{
internal static IReadOnlyList<StockSearchQueryProfile> All { get; } =
[
new(
StockMarket.Kospi,
DataSourceKind.Oracle,
"STOCK_SEARCH_KOSPI",
"""
SELECT STOCK_NAME, STOCK_CODE
FROM (
SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE
FROM T_STOCK
WHERE F_MKT_HALT = 'N'
AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%' ESCAPE '!'
ORDER BY UPPER(F_STOCK_WANNAME), F_STOCK_CODE
)
WHERE ROWNUM <= :row_limit
"""),
new(
StockMarket.Kosdaq,
DataSourceKind.Oracle,
"STOCK_SEARCH_KOSDAQ",
"""
SELECT STOCK_NAME, STOCK_CODE
FROM (
SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE
FROM T_KOSDAQ_STOCK
WHERE F_MKT_HALT = 'N'
AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%' ESCAPE '!'
ORDER BY UPPER(F_STOCK_WANNAME), F_STOCK_CODE
)
WHERE ROWNUM <= :row_limit
"""),
new(
StockMarket.NxtKospi,
DataSourceKind.MariaDb,
"STOCK_SEARCH_NXT_KOSPI",
"""
SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE
FROM N_STOCK a
INNER JOIN N_ONLINE b ON b.F_STOCK_CODE = a.F_STOCK_CODE
WHERE a.F_STOP_GUBUN = 'N'
AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%') ESCAPE '!'
ORDER BY UPPER(a.F_STOCK_NAME), a.F_STOCK_CODE
LIMIT @row_limit
"""),
new(
StockMarket.NxtKosdaq,
DataSourceKind.MariaDb,
"STOCK_SEARCH_NXT_KOSDAQ",
"""
SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE
FROM N_KOSDAQ_STOCK a
INNER JOIN N_KOSDAQ_ONLINE b ON b.F_STOCK_CODE = a.F_STOCK_CODE
WHERE a.F_STOP_GUBUN = 'N'
AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%') ESCAPE '!'
ORDER BY UPPER(a.F_STOCK_NAME), a.F_STOCK_CODE
LIMIT @row_limit
""")
];
}

View File

@@ -0,0 +1,587 @@
#nullable enable
using System.Data;
using System.Globalization;
using System.Text.RegularExpressions;
namespace MMoneyCoderSharp.Data;
public sealed record ThemeCatalogItem(
int InputIndex,
string ItemCode,
string ItemName);
public sealed record ThemeCatalogDefinition(
ThemeMarket Market,
string ThemeCode,
string ThemeTitle,
IReadOnlyList<ThemeCatalogItem> Items);
public interface IThemeCatalogPersistenceService
{
bool CanMutate { get; }
Task<string> SuggestNextCodeAsync(
ThemeMarket market,
CancellationToken cancellationToken = default);
Task<OperatorCatalogMutationReceipt> CreateAsync(
ThemeCatalogDefinition definition,
CancellationToken cancellationToken = default);
Task<OperatorCatalogMutationReceipt> ReplaceAsync(
ThemeSelectionIdentity expectedIdentity,
string newTitle,
IReadOnlyList<ThemeCatalogItem> items,
CancellationToken cancellationToken = default);
Task<OperatorCatalogMutationReceipt> DeleteAsync(
ThemeSelectionIdentity expectedIdentity,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Atomic migration of ThemeA/UC4 theme CRUD. KRX stays on Oracle and NXT stays
/// on MariaDB. Suggested codes are advisory; create uses serializable,
/// conditional insertion so a stale suggestion cannot overwrite another theme.
/// </summary>
public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalogPersistenceService
{
public const int MaximumItems = 240;
internal const string NextKrxCodeQueryName = "THEME_NEXT_CODE_KRX";
internal const string NextNxtCodeQueryName = "THEME_NEXT_CODE_NXT";
private const int MaximumThemeTitleLength = 128;
private const int MaximumItemNameLength = 200;
private const string NextKrxCodeSql = """
SELECT CASE
WHEN NVL(MAX(TO_NUMBER(TRIM(SB_CODE))), 0) < 99999999
THEN LPAD(
TO_CHAR(NVL(MAX(TO_NUMBER(TRIM(SB_CODE))), 0) + 1),
8,
'0')
ELSE 'EXHAUSTED'
END THEME_CODE
FROM SB_LIST
""";
private const string NextNxtCodeSql = """
SELECT CASE
WHEN SUM(
CASE
WHEN TRIM(SB_CODE) REGEXP '^[0-9]{8}$' THEN 0
ELSE 1
END) > 0
THEN 'INVALID'
WHEN COALESCE(MAX(CAST(TRIM(SB_CODE) AS UNSIGNED)), 0) < 99999999
THEN LPAD(
CAST(COALESCE(MAX(CAST(TRIM(SB_CODE) AS UNSIGNED)), 0) + 1 AS CHAR),
8,
'0')
ELSE 'EXHAUSTED'
END THEME_CODE
FROM SB_LIST
""";
private readonly IDataQueryExecutor _queryExecutor;
private readonly IOperatorCatalogMutationExecutor? _mutationExecutor;
public LegacyThemeCatalogPersistenceService(IDataQueryExecutor queryExecutor)
: this(queryExecutor, null)
{
}
public LegacyThemeCatalogPersistenceService(
IDataQueryExecutor queryExecutor,
IOperatorCatalogMutationExecutor? mutationExecutor)
{
_queryExecutor = queryExecutor ?? throw new ArgumentNullException(nameof(queryExecutor));
_mutationExecutor = mutationExecutor;
}
public bool CanMutate => _mutationExecutor is not null;
public async Task<string> SuggestNextCodeAsync(
ThemeMarket market,
CancellationToken cancellationToken = default)
{
var profile = ThemeMutationProfiles.For(market);
cancellationToken.ThrowIfCancellationRequested();
var query = new DataQuerySpec(
market == ThemeMarket.Krx ? NextKrxCodeSql : NextNxtCodeSql);
query.ValidateFor(profile.Source);
var table = await _queryExecutor.ExecuteAsync(
profile.Source,
market == ThemeMarket.Krx ? NextKrxCodeQueryName : NextNxtCodeQueryName,
query,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (table is null ||
table.Columns.Count != 1 ||
!string.Equals(table.Columns[0].ColumnName, "THEME_CODE", StringComparison.Ordinal) ||
table.Columns[0].DataType != typeof(string) ||
table.Rows.Count != 1 ||
table.Rows[0][0] is not string code ||
!ThemeCodePattern().IsMatch(code))
{
throw new ThemeSelectionDataException(
"Theme next-code data has an invalid schema, value, or exhausted code range.");
}
return code;
}
public Task<OperatorCatalogMutationReceipt> CreateAsync(
ThemeCatalogDefinition definition,
CancellationToken cancellationToken = default)
{
var canonical = ValidateDefinition(definition, nameof(definition));
var profile = ThemeMutationProfiles.For(canonical.Market);
var commands = new List<OperatorCatalogDbCommand>(canonical.Items.Count + 1)
{
profile.CreateThemeCommand(canonical.ThemeCode, canonical.ThemeTitle)
};
commands.AddRange(canonical.Items.Select(item =>
profile.CreateItemCommand(canonical.ThemeCode, item)));
return ExecuteAsync(
new OperatorCatalogDbTransaction(
Guid.NewGuid(),
OperatorCatalogMutationKind.CreateTheme,
profile.Source,
canonical.ThemeCode,
commands),
cancellationToken);
}
public Task<OperatorCatalogMutationReceipt> ReplaceAsync(
ThemeSelectionIdentity expectedIdentity,
string newTitle,
IReadOnlyList<ThemeCatalogItem> items,
CancellationToken cancellationToken = default)
{
var expected = ValidateIdentity(expectedIdentity, nameof(expectedIdentity));
var title = ValidateThemeTitle(newTitle, expected.Market, nameof(newTitle));
var canonicalItems = ValidateItems(items, expected.Market, nameof(items));
var profile = ThemeMutationProfiles.For(expected.Market);
var commands = new List<OperatorCatalogDbCommand>(canonicalItems.Count + 2)
{
profile.CreateUpdateCommand(expected, title),
profile.CreateDeleteItemsCommand(expected.ThemeCode)
};
commands.AddRange(canonicalItems.Select(item =>
profile.CreateItemCommand(expected.ThemeCode, item)));
return ExecuteAsync(
new OperatorCatalogDbTransaction(
Guid.NewGuid(),
OperatorCatalogMutationKind.ReplaceTheme,
profile.Source,
expected.ThemeCode,
commands),
cancellationToken);
}
public Task<OperatorCatalogMutationReceipt> DeleteAsync(
ThemeSelectionIdentity expectedIdentity,
CancellationToken cancellationToken = default)
{
var expected = ValidateIdentity(expectedIdentity, nameof(expectedIdentity));
var profile = ThemeMutationProfiles.For(expected.Market);
var transaction = new OperatorCatalogDbTransaction(
Guid.NewGuid(),
OperatorCatalogMutationKind.DeleteTheme,
profile.Source,
expected.ThemeCode,
[
profile.CreateDeleteItemsCommand(expected.ThemeCode),
profile.CreateDeleteThemeCommand(expected)
]);
return ExecuteAsync(transaction, cancellationToken);
}
private async Task<OperatorCatalogMutationReceipt> ExecuteAsync(
OperatorCatalogDbTransaction transaction,
CancellationToken cancellationToken)
{
var executor = _mutationExecutor ?? throw new InvalidOperationException(
"Theme catalog persistence is configured for read-only access.");
cancellationToken.ThrowIfCancellationRequested();
OperatorCatalogDbTransactionResult result;
try
{
result = await executor.ExecuteTransactionAsync(transaction, cancellationToken)
.ConfigureAwait(false);
}
catch (OperatorCatalogMutationException)
{
throw;
}
catch (Exception exception)
{
throw new OperatorCatalogMutationException(
$"The {transaction.Kind} transaction outcome is unknown; do not retry automatically.",
outcomeUnknown: true,
exception);
}
if (result is null ||
result.OperationId != transaction.OperationId ||
result.AffectedRows is null ||
result.AffectedRows.Count != transaction.Commands.Count ||
result.AffectedRows.Any(static count => count < 0))
{
throw new OperatorCatalogMutationException(
$"The {transaction.Kind} transaction returned an ambiguous commit receipt; do not retry automatically.",
outcomeUnknown: true);
}
for (var index = 0; index < transaction.Commands.Count; index++)
{
if (transaction.Commands[index].AffectedRowsRule ==
OperatorCatalogAffectedRowsRule.ExactlyOne &&
result.AffectedRows[index] != 1)
{
throw new OperatorCatalogMutationException(
$"The {transaction.Kind} transaction returned an ambiguous affected-row receipt; do not retry automatically.",
outcomeUnknown: true);
}
}
return new OperatorCatalogMutationReceipt(
result.OperationId,
transaction.Kind,
result.CommittedAt);
}
private static ThemeCatalogDefinition ValidateDefinition(
ThemeCatalogDefinition definition,
string parameterName)
{
ArgumentNullException.ThrowIfNull(definition);
if (!Enum.IsDefined(definition.Market))
{
throw new ArgumentOutOfRangeException(parameterName, "The theme market is invalid.");
}
var code = ValidateThemeCode(definition.ThemeCode, parameterName);
var title = ValidateThemeTitle(definition.ThemeTitle, definition.Market, parameterName);
var items = ValidateItems(definition.Items, definition.Market, parameterName);
return new ThemeCatalogDefinition(definition.Market, code, title, items);
}
private static ThemeSelectionIdentity ValidateIdentity(
ThemeSelectionIdentity identity,
string parameterName)
{
ArgumentNullException.ThrowIfNull(identity);
if (!Enum.IsDefined(identity.Market) || !Enum.IsDefined(identity.Session) ||
identity.Market == ThemeMarket.Krx && identity.Session != ThemeSession.NotApplicable ||
identity.Market == ThemeMarket.Nxt &&
identity.Session is not (ThemeSession.PreMarket or ThemeSession.AfterMarket))
{
throw new ArgumentException("The theme identity is invalid.", parameterName);
}
return identity with
{
ThemeCode = ValidateThemeCode(identity.ThemeCode, parameterName),
ThemeTitle = ValidateThemeTitle(identity.ThemeTitle, identity.Market, parameterName)
};
}
private static IReadOnlyList<ThemeCatalogItem> ValidateItems(
IReadOnlyList<ThemeCatalogItem> items,
ThemeMarket market,
string parameterName)
{
ArgumentNullException.ThrowIfNull(items);
if (items.Count > MaximumItems)
{
throw new ArgumentOutOfRangeException(
parameterName,
$"A theme can contain at most {MaximumItems} items.");
}
var codes = new HashSet<string>(StringComparer.Ordinal);
var names = new HashSet<string>(StringComparer.Ordinal);
var canonical = new ThemeCatalogItem[items.Count];
for (var index = 0; index < items.Count; index++)
{
var item = items[index] ?? throw new ArgumentException(
"A theme item is missing.",
parameterName);
if (item.InputIndex != index)
{
throw new ArgumentException(
"Theme item indexes must be contiguous and zero based.",
parameterName);
}
var code = ValidateCanonicalText(item.ItemCode, 13, parameterName);
var codePattern = market == ThemeMarket.Krx
? KrxItemCodePattern()
: NxtItemCodePattern();
if (!codePattern.IsMatch(code))
{
throw new ArgumentException(
"A theme item code does not match its market.",
parameterName);
}
var name = ValidateCanonicalText(
item.ItemName,
MaximumItemNameLength,
parameterName);
if (!codes.Add(code) || !names.Add(name))
{
throw new ArgumentException(
"Theme item codes and names must each be unique.",
parameterName);
}
canonical[index] = new ThemeCatalogItem(index, code, name);
}
return Array.AsReadOnly(canonical);
}
private static string ValidateThemeCode(string value, string parameterName)
{
var code = ValidateCanonicalText(value, 8, parameterName);
if (!ThemeCodePattern().IsMatch(code))
{
throw new ArgumentException("A theme code must contain eight digits.", parameterName);
}
return code;
}
private static string ValidateThemeTitle(
string value,
ThemeMarket market,
string parameterName)
{
var title = ValidateCanonicalText(value, MaximumThemeTitleLength, parameterName);
if (market == ThemeMarket.Nxt &&
title.Contains("(NXT)", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
"The NXT display suffix is not part of the stored theme title.",
parameterName);
}
return title;
}
private static string ValidateCanonicalText(
string value,
int maximumLength,
string parameterName)
{
ArgumentNullException.ThrowIfNull(value);
if (value.Length is < 1 || value.Length > maximumLength ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
!IsSafeText(value))
{
throw new ArgumentException("A theme catalog value is invalid.", parameterName);
}
return 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;
}
[GeneratedRegex("^[0-9]{8}$", RegexOptions.CultureInvariant)]
private static partial Regex ThemeCodePattern();
[GeneratedRegex("^[PD][A-Za-z0-9]{1,12}$", RegexOptions.CultureInvariant)]
private static partial Regex KrxItemCodePattern();
[GeneratedRegex("^[XT][A-Za-z0-9]{1,12}$", RegexOptions.CultureInvariant)]
private static partial Regex NxtItemCodePattern();
}
internal sealed record ThemeMutationProfile(
ThemeMarket Market,
DataSourceKind Source,
string DatabaseMarket,
char Marker,
string CreateSql,
string UpdateSql,
string DeleteItemsSql,
string InsertItemSql,
string DeleteSql)
{
internal OperatorCatalogDbCommand CreateThemeCommand(string code, string title) =>
Command(
OperatorCatalogDbCommandKind.InsertTheme,
CreateSql,
OperatorCatalogAffectedRowsRule.ExactlyOne,
String("theme_code", code),
String("theme_title", title),
String("conflict_code", code),
String("conflict_title", title));
internal OperatorCatalogDbCommand CreateUpdateCommand(
ThemeSelectionIdentity expected,
string newTitle) =>
Command(
OperatorCatalogDbCommandKind.UpdateTheme,
UpdateSql,
OperatorCatalogAffectedRowsRule.ExactlyOne,
String("new_title", newTitle),
String("theme_code", expected.ThemeCode),
String("expected_title", expected.ThemeTitle),
String("duplicate_title", newTitle),
String("other_code", expected.ThemeCode));
internal OperatorCatalogDbCommand CreateDeleteItemsCommand(string code) =>
Command(
OperatorCatalogDbCommandKind.DeleteThemeItems,
DeleteItemsSql,
OperatorCatalogAffectedRowsRule.AnyNonNegative,
String("theme_code", code));
internal OperatorCatalogDbCommand CreateItemCommand(
string code,
ThemeCatalogItem item) =>
Command(
OperatorCatalogDbCommandKind.InsertThemeItem,
InsertItemSql,
OperatorCatalogAffectedRowsRule.ExactlyOne,
String("theme_code", code),
String("item_code", item.ItemCode),
String("item_name", item.ItemName),
new OperatorCatalogDbParameter("item_index", item.InputIndex, DbType.Int32));
internal OperatorCatalogDbCommand CreateDeleteThemeCommand(ThemeSelectionIdentity expected) =>
Command(
OperatorCatalogDbCommandKind.DeleteTheme,
DeleteSql,
OperatorCatalogAffectedRowsRule.ExactlyOne,
String("theme_code", expected.ThemeCode),
String("expected_title", expected.ThemeTitle));
private static OperatorCatalogDbParameter String(string name, string value) =>
new(name, value, DbType.String);
private static OperatorCatalogDbCommand Command(
OperatorCatalogDbCommandKind kind,
string sql,
OperatorCatalogAffectedRowsRule rule,
params OperatorCatalogDbParameter[] parameters) =>
new(kind, sql, rule, parameters);
}
internal static class ThemeMutationProfiles
{
private static readonly ThemeMutationProfile Krx = new(
ThemeMarket.Krx,
DataSourceKind.Oracle,
"KRX",
':',
"""
INSERT INTO SB_LIST(SB_CODE, SB_TITLE, SB_MARKET)
SELECT :theme_code, :theme_title, 'KRX'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM SB_LIST WHERE SB_CODE = :conflict_code)
AND NOT EXISTS (
SELECT 1
FROM SB_LIST
WHERE SB_MARKET = 'KRX'
AND SB_TITLE = :conflict_title)
""",
"""
UPDATE SB_LIST target
SET SB_TITLE = :new_title
WHERE target.SB_CODE = :theme_code
AND target.SB_TITLE = :expected_title
AND target.SB_MARKET = 'KRX'
AND NOT EXISTS (
SELECT 1
FROM SB_LIST other_row
WHERE other_row.SB_MARKET = 'KRX'
AND other_row.SB_TITLE = :duplicate_title
AND other_row.SB_CODE <> :other_code)
""",
"DELETE FROM SB_ITEM WHERE SB_M_CODE = :theme_code",
"""
INSERT INTO SB_ITEM(SB_M_CODE, SB_I_CODE, SB_I_NAME, SB_I_INDEX)
VALUES (:theme_code, :item_code, :item_name, :item_index)
""",
"""
DELETE FROM SB_LIST
WHERE SB_CODE = :theme_code
AND SB_TITLE = :expected_title
AND SB_MARKET = 'KRX'
""");
private static readonly ThemeMutationProfile Nxt = new(
ThemeMarket.Nxt,
DataSourceKind.MariaDb,
"NXT",
'@',
"""
INSERT INTO SB_LIST(SB_CODE, SB_TITLE, SB_MARKET)
SELECT @theme_code, @theme_title, 'NXT'
WHERE NOT EXISTS (
SELECT 1 FROM SB_LIST WHERE SB_CODE = @conflict_code)
AND NOT EXISTS (
SELECT 1
FROM SB_LIST
WHERE SB_MARKET = 'NXT'
AND SB_TITLE = @conflict_title)
""",
"""
UPDATE SB_LIST target
LEFT JOIN SB_LIST other_row
ON other_row.SB_MARKET = 'NXT'
AND other_row.SB_TITLE = @duplicate_title
AND other_row.SB_CODE <> @other_code
SET target.SB_TITLE = @new_title
WHERE target.SB_CODE = @theme_code
AND target.SB_TITLE = @expected_title
AND target.SB_MARKET = 'NXT'
AND other_row.SB_CODE IS NULL
""",
"DELETE FROM SB_ITEM WHERE SB_M_CODE = @theme_code",
"""
INSERT INTO SB_ITEM(SB_M_CODE, SB_I_CODE, SB_I_NAME, SB_I_INDEX)
VALUES (@theme_code, @item_code, @item_name, @item_index)
""",
"""
DELETE FROM SB_LIST
WHERE SB_CODE = @theme_code
AND SB_TITLE = @expected_title
AND SB_MARKET = 'NXT'
""");
internal static ThemeMutationProfile For(ThemeMarket market) => market switch
{
ThemeMarket.Krx => Krx,
ThemeMarket.Nxt => Nxt,
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported theme market.")
};
}

View File

@@ -0,0 +1,620 @@
#nullable enable
using System.Data;
using System.Globalization;
using System.Text.RegularExpressions;
namespace MMoneyCoderSharp.Data;
public enum ThemeMarket
{
Krx,
Nxt
}
public enum ThemeSession
{
NotApplicable,
PreMarket,
AfterMarket
}
/// <summary>
/// Closed theme identity carried from selector to preview and eventually to the
/// paged scene request. Market and NXT session are explicit; the UI must never
/// infer either value from a display-title suffix.
/// </summary>
public sealed record ThemeSelectionIdentity(
ThemeMarket Market,
ThemeSession Session,
string ThemeCode,
string ThemeTitle);
public sealed record ThemeSelectorItem(
ThemeSelectionIdentity Identity,
DataSourceKind Source);
public sealed record ThemeSearchResult(
string Query,
ThemeSession NxtSession,
DateTimeOffset RetrievedAt,
IReadOnlyList<ThemeSelectorItem> Items,
bool IsTruncated);
public sealed record ThemeItemPreview(
int InputIndex,
string ItemCode,
string ItemName);
public sealed record ThemePreviewResult(
ThemeSelectionIdentity Identity,
DateTimeOffset RetrievedAt,
IReadOnlyList<ThemeItemPreview> Items,
bool IsTruncated);
public interface IThemeSelectionService
{
Task<ThemeSearchResult> SearchAsync(
string query,
ThemeSession nxtSession,
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default);
Task<ThemePreviewResult> PreviewAsync(
ThemeSelectionIdentity identity,
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default);
}
public sealed class ThemeSelectionDataException : Exception
{
public ThemeSelectionDataException(string message)
: base(message)
{
}
}
/// <summary>
/// Read-only migration of UC4's theme list and selected-item preview. KRX data
/// remains on Oracle and NXT data remains on MariaDB, matching both the original
/// Nxt_ViewQuery path and the migrated PAGED_THEME/PAGED_NXT_THEME loaders.
/// </summary>
public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
{
public const int DefaultMaximumResults = 100;
public const int MaximumResults = 500;
public const int MaximumQueryLength = 64;
public const int DefaultMaximumPreviewItems = 240;
public const int MaximumPreviewItems = 240;
private const int MaximumThemeTitleLength = 128;
private const int MaximumItemNameLength = 200;
private const int MaximumItemIndex = 9_999;
private static readonly string[] SearchColumns =
["THEME_TITLE", "THEME_CODE", "THEME_MARKET"];
private static readonly string[] PreviewColumns =
[
"THEME_TITLE",
"THEME_CODE",
"THEME_MARKET",
"ITEM_NAME",
"ITEM_CODE",
"ITEM_INDEX"
];
private readonly IDataQueryExecutor _executor;
public LegacyThemeSelectionService(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<ThemeSearchResult> SearchAsync(
string query,
ThemeSession nxtSession,
int maximumResults = DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var normalizedQuery = ValidateAndNormalizeSearchQuery(query);
ValidateNxtSession(nxtSession, nameof(nxtSession));
ValidateLimit(maximumResults, MaximumResults, nameof(maximumResults));
cancellationToken.ThrowIfCancellationRequested();
var escapedQuery = EscapeLikePattern(normalizedQuery.ToUpperInvariant());
var perMarketLimit = checked(maximumResults + 1);
var matches = new List<ThemeSelectorItem>(perMarketLimit * ThemeQueries.All.Count);
foreach (var profile in ThemeQueries.All)
{
cancellationToken.ThrowIfCancellationRequested();
var table = await _executor.ExecuteAsync(
profile.Source,
profile.SearchName,
profile.CreateSearchSpec(escapedQuery, perMarketLimit),
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
AppendSearchRows(table, profile, nxtSession, perMarketLimit, matches);
}
var ordered = matches
.OrderBy(static item => item.Identity.Market)
.ThenBy(static item => item.Identity.ThemeTitle, StringComparer.Ordinal)
.ThenBy(static item => item.Identity.ThemeCode, StringComparer.Ordinal)
.Take(maximumResults)
.ToArray();
return new ThemeSearchResult(
normalizedQuery,
nxtSession,
DateTimeOffset.Now,
ordered,
matches.Count > maximumResults);
}
public async Task<ThemePreviewResult> PreviewAsync(
ThemeSelectionIdentity identity,
int maximumItems = DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default)
{
var canonicalIdentity = ValidateIdentity(identity);
ValidateLimit(maximumItems, MaximumPreviewItems, nameof(maximumItems));
cancellationToken.ThrowIfCancellationRequested();
var profile = ThemeQueries.For(canonicalIdentity.Market);
var rowLimit = checked(maximumItems + 1);
var table = await _executor.ExecuteAsync(
profile.Source,
profile.PreviewName,
profile.CreatePreviewSpec(canonicalIdentity, rowLimit),
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var items = MapPreviewRows(table, profile, canonicalIdentity, rowLimit);
return new ThemePreviewResult(
canonicalIdentity,
DateTimeOffset.Now,
items.Take(maximumItems).ToArray(),
items.Count > maximumItems);
}
private static void AppendSearchRows(
DataTable? table,
ThemeQueryProfile profile,
ThemeSession nxtSession,
int rowLimit,
ICollection<ThemeSelectorItem> destination)
{
ValidateExactStringSchema(table, SearchColumns, rowLimit, profile.SearchName);
var titles = new HashSet<string>(StringComparer.Ordinal);
var codes = new HashSet<string>(StringComparer.Ordinal);
foreach (DataRow row in table!.Rows)
{
var title = ReadCanonicalString(row, 0, MaximumThemeTitleLength, profile.SearchName);
var code = ReadThemeCode(row, 1, profile.SearchName);
var market = ReadCanonicalString(row, 2, 3, profile.SearchName);
if (!string.Equals(market, profile.DatabaseMarket, StringComparison.Ordinal))
{
throw InvalidData(profile.SearchName, "market identity");
}
// PAGED_THEME resolves by title and UC4 previews by code. Both keys
// must be unique inside one market or the selection is ambiguous.
if (!titles.Add(title) || !codes.Add(code))
{
throw InvalidData(profile.SearchName, "duplicate title or code");
}
var session = profile.Market == ThemeMarket.Krx
? ThemeSession.NotApplicable
: nxtSession;
destination.Add(new ThemeSelectorItem(
new ThemeSelectionIdentity(profile.Market, session, code, title),
profile.Source));
}
}
private static IReadOnlyList<ThemeItemPreview> MapPreviewRows(
DataTable? table,
ThemeQueryProfile profile,
ThemeSelectionIdentity identity,
int rowLimit)
{
ValidateExactStringSchema(table, PreviewColumns, rowLimit, profile.PreviewName);
if (table!.Rows.Count < 1)
{
throw InvalidData(profile.PreviewName, "missing selected theme");
}
var items = new List<ThemeItemPreview>(table.Rows.Count);
var itemCodes = new HashSet<string>(StringComparer.Ordinal);
var indexes = new HashSet<int>();
var sawEmptyThemeRow = false;
foreach (DataRow row in table.Rows)
{
var title = ReadCanonicalString(row, 0, MaximumThemeTitleLength, profile.PreviewName);
var code = ReadThemeCode(row, 1, profile.PreviewName);
var market = ReadCanonicalString(row, 2, 3, profile.PreviewName);
if (!string.Equals(title, identity.ThemeTitle, StringComparison.Ordinal) ||
!string.Equals(code, identity.ThemeCode, StringComparison.Ordinal) ||
!string.Equals(market, profile.DatabaseMarket, StringComparison.Ordinal))
{
throw InvalidData(profile.PreviewName, "selected theme identity");
}
var nameValue = row[3];
var codeValue = row[4];
var indexValue = row[5];
var isEmptyThemeRow = nameValue is DBNull && codeValue is DBNull && indexValue is DBNull;
if (isEmptyThemeRow)
{
if (table.Rows.Count != 1)
{
throw InvalidData(profile.PreviewName, "empty item sentinel");
}
sawEmptyThemeRow = true;
continue;
}
if (nameValue is DBNull || codeValue is DBNull || indexValue is DBNull)
{
throw InvalidData(profile.PreviewName, "partial item identity");
}
var itemName = ReadCanonicalString(row, 3, MaximumItemNameLength, profile.PreviewName);
var itemCode = ReadCanonicalString(row, 4, 13, profile.PreviewName);
if (!IsItemCodeForMarket(itemCode, identity.Market))
{
throw InvalidData(profile.PreviewName, "item code");
}
var rawIndex = ReadCanonicalString(row, 5, 4, profile.PreviewName);
if (!int.TryParse(rawIndex, NumberStyles.None, CultureInfo.InvariantCulture, out var inputIndex) ||
inputIndex is < 0 or > MaximumItemIndex ||
!string.Equals(
rawIndex,
inputIndex.ToString(CultureInfo.InvariantCulture),
StringComparison.Ordinal))
{
throw InvalidData(profile.PreviewName, "item index");
}
if (!itemCodes.Add(itemCode) || !indexes.Add(inputIndex))
{
throw InvalidData(profile.PreviewName, "duplicate item code or index");
}
items.Add(new ThemeItemPreview(inputIndex, itemCode, itemName));
}
if (sawEmptyThemeRow)
{
return Array.Empty<ThemeItemPreview>();
}
for (var index = 1; index < items.Count; index++)
{
if (items[index - 1].InputIndex >= items[index].InputIndex)
{
throw InvalidData(profile.PreviewName, "item order");
}
}
return items;
}
private static ThemeSelectionIdentity ValidateIdentity(ThemeSelectionIdentity identity)
{
ArgumentNullException.ThrowIfNull(identity);
if (!Enum.IsDefined(identity.Market) || !Enum.IsDefined(identity.Session))
{
throw new ArgumentException("The theme selection identity is invalid.", nameof(identity));
}
if (identity.Market == ThemeMarket.Krx && identity.Session != ThemeSession.NotApplicable ||
identity.Market == ThemeMarket.Nxt &&
identity.Session is not (ThemeSession.PreMarket or ThemeSession.AfterMarket))
{
throw new ArgumentException(
"The theme selection market and session do not form a safe identity.",
nameof(identity));
}
var title = ValidateCanonicalInput(
identity.ThemeTitle,
MaximumThemeTitleLength,
nameof(identity));
var code = ValidateCanonicalInput(identity.ThemeCode, 8, nameof(identity));
if (!ThemeCodePattern().IsMatch(code))
{
throw new ArgumentException("The theme selection code is invalid.", nameof(identity));
}
return identity with { ThemeTitle = title, ThemeCode = code };
}
private static string ValidateAndNormalizeSearchQuery(string query)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsSafeText(query))
{
throw new ArgumentException("The theme search query is invalid.", nameof(query));
}
var normalized = query.Trim();
if (normalized.Length > MaximumQueryLength || !IsSafeText(normalized))
{
throw new ArgumentException(
$"A theme search query must contain at most {MaximumQueryLength} visible characters.",
nameof(query));
}
return normalized;
}
private static string ValidateCanonicalInput(
string value,
int maximumLength,
string parameterName)
{
ArgumentNullException.ThrowIfNull(value);
if (value.Length is < 1 || value.Length > maximumLength ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
!IsSafeText(value))
{
throw new ArgumentException("The theme selection value is invalid.", parameterName);
}
return value;
}
private static void ValidateNxtSession(ThemeSession session, string parameterName)
{
if (session is not (ThemeSession.PreMarket or ThemeSession.AfterMarket))
{
throw new ArgumentOutOfRangeException(
parameterName,
session,
"An explicit NXT pre-market or after-market session is required.");
}
}
private static void ValidateLimit(int value, int maximum, string parameterName)
{
if (value is < 1 || value > maximum)
{
throw new ArgumentOutOfRangeException(
parameterName,
value,
$"The requested limit must be between 1 and {maximum}.");
}
}
private static void ValidateExactStringSchema(
DataTable? table,
IReadOnlyList<string> columns,
int rowLimit,
string queryName)
{
if (table is null || table.Columns.Count != columns.Count || table.Rows.Count > rowLimit)
{
throw InvalidData(queryName, "schema or row bound");
}
for (var index = 0; index < columns.Count; index++)
{
if (!string.Equals(
table.Columns[index].ColumnName,
columns[index],
StringComparison.Ordinal) ||
table.Columns[index].DataType != typeof(string))
{
throw InvalidData(queryName, "schema");
}
}
}
private static string ReadCanonicalString(
DataRow row,
int ordinal,
int maximumLength,
string queryName)
{
if (row[ordinal] is not string value ||
value.Length is < 1 || value.Length > maximumLength ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
!IsSafeText(value))
{
throw InvalidData(queryName, "value");
}
return value;
}
private static string ReadThemeCode(DataRow row, int ordinal, string queryName)
{
var code = ReadCanonicalString(row, ordinal, 8, queryName);
if (!ThemeCodePattern().IsMatch(code))
{
throw InvalidData(queryName, "theme code");
}
return code;
}
private static bool IsItemCodeForMarket(string value, ThemeMarket market)
{
var pattern = market == ThemeMarket.Krx
? KrxItemCodePattern()
: NxtItemCodePattern();
return pattern.IsMatch(value);
}
private static string EscapeLikePattern(string value) => value
.Replace("!", "!!", StringComparison.Ordinal)
.Replace("%", "!%", StringComparison.Ordinal)
.Replace("_", "!_", StringComparison.Ordinal);
private static bool IsSafeText(string value)
{
for (var index = 0; index < value.Length; index++)
{
var character = value[index];
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 static ThemeSelectionDataException InvalidData(string queryName, string detail) =>
new($"Theme selection data from {queryName} contains an invalid {detail}.");
[GeneratedRegex("^[0-9]{8}$", RegexOptions.CultureInvariant)]
private static partial Regex ThemeCodePattern();
[GeneratedRegex("^[PD][A-Za-z0-9]{1,12}$", RegexOptions.CultureInvariant)]
private static partial Regex KrxItemCodePattern();
[GeneratedRegex("^[XT][A-Za-z0-9]{1,12}$", RegexOptions.CultureInvariant)]
private static partial Regex NxtItemCodePattern();
}
internal sealed record ThemeQueryProfile(
ThemeMarket Market,
DataSourceKind Source,
string DatabaseMarket,
string SearchName,
string PreviewName,
string SearchSql,
string PreviewSql)
{
internal DataQuerySpec CreateSearchSpec(string escapedQuery, int rowLimit)
{
var spec = new DataQuerySpec(
SearchSql,
[
new DataQueryParameter("search_term", escapedQuery, DbType.String),
new DataQueryParameter("row_limit", rowLimit, DbType.Int32)
]);
spec.ValidateFor(Source);
return spec;
}
internal DataQuerySpec CreatePreviewSpec(ThemeSelectionIdentity identity, int rowLimit)
{
var spec = new DataQuerySpec(
PreviewSql,
[
new DataQueryParameter("theme_code", identity.ThemeCode, DbType.String),
new DataQueryParameter("theme_title", identity.ThemeTitle, DbType.String),
new DataQueryParameter("row_limit", rowLimit, DbType.Int32)
]);
spec.ValidateFor(Source);
return spec;
}
}
internal static class ThemeQueries
{
private static readonly ThemeQueryProfile Krx = new(
ThemeMarket.Krx,
DataSourceKind.Oracle,
"KRX",
"THEME_SEARCH_KRX",
"THEME_PREVIEW_KRX",
"""
SELECT THEME_TITLE, THEME_CODE, THEME_MARKET
FROM (
SELECT SB_TITLE THEME_TITLE,
SB_CODE THEME_CODE,
SB_MARKET THEME_MARKET
FROM SB_LIST
WHERE SB_MARKET = 'KRX'
AND UPPER(SB_TITLE) LIKE '%' || :search_term || '%' ESCAPE '!'
ORDER BY UPPER(SB_TITLE), SB_CODE
)
WHERE ROWNUM <= :row_limit
""",
"""
SELECT THEME_TITLE, THEME_CODE, THEME_MARKET,
ITEM_NAME, ITEM_CODE, ITEM_INDEX
FROM (
SELECT l.SB_TITLE THEME_TITLE,
l.SB_CODE THEME_CODE,
l.SB_MARKET THEME_MARKET,
a.SB_I_NAME ITEM_NAME,
a.SB_I_CODE ITEM_CODE,
TO_CHAR(a.SB_I_INDEX, 'FM999999990') ITEM_INDEX
FROM SB_LIST l
LEFT JOIN (
SELECT i.SB_M_CODE, i.SB_I_NAME, i.SB_I_CODE, i.SB_I_INDEX
FROM SB_ITEM i
JOIN v_all_stock s
ON SUBSTR(i.SB_I_CODE, 2, 12) = s.F_STOCK_CODE
WHERE s.F_MKT_HALT = 'N'
) a ON l.SB_CODE = a.SB_M_CODE
WHERE l.SB_CODE = :theme_code
AND l.SB_TITLE = :theme_title
AND l.SB_MARKET = 'KRX'
ORDER BY a.SB_I_INDEX
)
WHERE ROWNUM <= :row_limit
""");
private static readonly ThemeQueryProfile Nxt = new(
ThemeMarket.Nxt,
DataSourceKind.MariaDb,
"NXT",
"THEME_SEARCH_NXT",
"THEME_PREVIEW_NXT",
"""
SELECT SB_TITLE THEME_TITLE,
SB_CODE THEME_CODE,
SB_MARKET THEME_MARKET
FROM SB_LIST
WHERE SB_MARKET = 'NXT'
AND UPPER(SB_TITLE) LIKE CONCAT('%', @search_term, '%') ESCAPE '!'
ORDER BY UPPER(SB_TITLE), SB_CODE
LIMIT @row_limit
""",
"""
SELECT l.SB_TITLE THEME_TITLE,
l.SB_CODE THEME_CODE,
l.SB_MARKET THEME_MARKET,
a.SB_I_NAME ITEM_NAME,
a.SB_I_CODE ITEM_CODE,
CAST(a.SB_I_INDEX AS CHAR) ITEM_INDEX
FROM SB_LIST l
LEFT JOIN (
SELECT i.SB_M_CODE, i.SB_I_NAME, i.SB_I_CODE, i.SB_I_INDEX
FROM SB_ITEM i
JOIN v_all_stock s
ON SUBSTRING(i.SB_I_CODE, 2, 12) = s.F_STOCK_CODE
WHERE s.F_STOP_GUBUN = 'N'
) a ON l.SB_CODE = a.SB_M_CODE
WHERE l.SB_CODE = @theme_code
AND l.SB_TITLE = @theme_title
AND l.SB_MARKET = 'NXT'
ORDER BY a.SB_I_INDEX
LIMIT @row_limit
""");
internal static IReadOnlyList<ThemeQueryProfile> All { get; } = [Krx, Nxt];
internal static ThemeQueryProfile For(ThemeMarket market) => market switch
{
ThemeMarket.Krx => Krx,
ThemeMarket.Nxt => Nxt,
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported theme market.")
};
}

View File

@@ -0,0 +1,324 @@
#nullable enable
using System.Data;
using System.Globalization;
namespace MMoneyCoderSharp.Data;
public enum TradingHaltMarket
{
Kospi,
Kosdaq
}
public sealed record TradingHaltSelection(
TradingHaltMarket Market,
string StockCode,
string DisplayName);
public sealed record TradingHaltSelectionResult(
string Query,
DateTimeOffset RetrievedAt,
IReadOnlyList<TradingHaltSelection> Items,
bool IsTruncated);
public interface ITradingHaltSelectionService
{
Task<TradingHaltSelectionResult> SearchAsync(
string query = "",
int maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default);
}
public sealed class TradingHaltSelectionDataException : Exception
{
public TradingHaltSelectionDataException(string message)
: base(message)
{
}
}
/// <summary>
/// Reads the KOSPI and KOSDAQ trading-halt selectors used by legacy UC7.
/// The empty query returns the bounded selector contents; a non-empty query
/// applies the legacy name filter through a bound parameter. Database rows
/// must satisfy the exact market/code/name identity contract before they are
/// exposed to an operator surface.
/// </summary>
public sealed class LegacyTradingHaltSelectionService : ITradingHaltSelectionService
{
public const int DefaultMaximumResults = 100;
public const int MaximumResults = 500;
public const int MaximumQueryLength = 64;
private const int MaximumStockCodeLength = 32;
private const int MaximumDisplayNameLength = 200;
private const string StockCodeColumn = "STOCK_CODE";
private const string StockNameColumn = "STOCK_NAME";
private readonly IDataQueryExecutor _executor;
public LegacyTradingHaltSelectionService(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<TradingHaltSelectionResult> SearchAsync(
string query = "",
int maximumResults = DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var normalizedQuery = ValidateAndNormalizeQuery(query);
if (maximumResults is < 1 or > MaximumResults)
{
throw new ArgumentOutOfRangeException(
nameof(maximumResults),
$"The requested result limit must be between 1 and {MaximumResults}.");
}
cancellationToken.ThrowIfCancellationRequested();
var rowLimit = checked(maximumResults + 1);
var escapedQuery = EscapeLikePattern(normalizedQuery.ToUpperInvariant());
var matches = new List<TradingHaltSelection>(rowLimit * TradingHaltQueries.All.Count);
var identities = new HashSet<(TradingHaltMarket Market, string StockCode)>();
var lookupKeys = new HashSet<(TradingHaltMarket Market, string DisplayName)>();
// UC7 appends KOSPI followed by KOSDAQ. Keep that provider sequence,
// then apply a deterministic final order independent of provider row order.
foreach (var profile in TradingHaltQueries.All)
{
cancellationToken.ThrowIfCancellationRequested();
var spec = profile.CreateSpec(escapedQuery, rowLimit);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
profile.QueryName,
spec,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
AppendValidatedRows(
table,
profile,
rowLimit,
matches,
identities,
lookupKeys);
}
var ordered = matches
.OrderBy(static item => item.Market)
.ThenBy(static item => item.DisplayName, StringComparer.Ordinal)
.ThenBy(static item => item.StockCode, StringComparer.Ordinal)
.Take(maximumResults)
.ToArray();
return new TradingHaltSelectionResult(
normalizedQuery,
DateTimeOffset.Now,
ordered,
matches.Count > maximumResults);
}
private static string ValidateAndNormalizeQuery(string query)
{
ArgumentNullException.ThrowIfNull(query);
var normalized = query.Trim();
if (query.Length > MaximumQueryLength ||
normalized.Length > MaximumQueryLength ||
!IsSafeText(query) ||
!IsSafeText(normalized))
{
throw new ArgumentException(
$"A trading-halt search query must contain at most {MaximumQueryLength} safe characters.",
nameof(query));
}
return normalized;
}
private static string EscapeLikePattern(string value) => value
.Replace("!", "!!", StringComparison.Ordinal)
.Replace("%", "!%", StringComparison.Ordinal)
.Replace("_", "!_", StringComparison.Ordinal);
private static void AppendValidatedRows(
DataTable? table,
TradingHaltQueryProfile profile,
int rowLimit,
ICollection<TradingHaltSelection> destination,
ISet<(TradingHaltMarket Market, string StockCode)> identities,
ISet<(TradingHaltMarket Market, string DisplayName)> lookupKeys)
{
if (table is null ||
table.Columns.Count != 2 ||
!HasExactStringColumn(table.Columns[0], StockCodeColumn) ||
!HasExactStringColumn(table.Columns[1], StockNameColumn) ||
table.Rows.Count > rowLimit)
{
throw InvalidSchema(profile.QueryName);
}
foreach (DataRow row in table.Rows)
{
if (row[0] is not string stockCode || row[1] is not string displayName)
{
throw InvalidSchema(profile.QueryName);
}
if (!IsCanonicalDatabaseValue(stockCode, MaximumStockCodeLength) ||
!IsCanonicalDatabaseValue(displayName, MaximumDisplayNameLength))
{
throw new TradingHaltSelectionDataException(
$"Trading-halt selector data from {profile.QueryName} contains an invalid value.");
}
if (!identities.Add((profile.Market, stockCode)))
{
throw new TradingHaltSelectionDataException(
$"Trading-halt selector data from {profile.QueryName} contains a duplicate market/code identity.");
}
// Legacy downstream requests resolve a domestic stock by market and
// display name. Distinct codes with the same key would be ambiguous.
if (!lookupKeys.Add((profile.Market, displayName)))
{
throw new TradingHaltSelectionDataException(
$"Trading-halt selector data from {profile.QueryName} contains an ambiguous market/name lookup key.");
}
destination.Add(new TradingHaltSelection(profile.Market, stockCode, displayName));
}
}
private static bool HasExactStringColumn(DataColumn column, string name) =>
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string);
private static bool IsCanonicalDatabaseValue(string value, int maximumLength) =>
value.Length is > 0 &&
value.Length <= maximumLength &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
IsSafeText(value);
private static bool IsSafeText(string value)
{
for (var index = 0; index < value.Length; index++)
{
var character = value[index];
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 static TradingHaltSelectionDataException InvalidSchema(string queryName) =>
new($"Trading-halt selector data from {queryName} does not match the required schema or row bound.");
}
internal sealed record TradingHaltQueryProfile(
TradingHaltMarket Market,
string QueryName,
string AllSql,
string FilteredSql)
{
internal DataQuerySpec CreateSpec(string escapedQuery, int rowLimit)
{
DataQuerySpec spec;
if (escapedQuery.Length == 0)
{
spec = new DataQuerySpec(
AllSql,
[new DataQueryParameter("row_limit", rowLimit, DbType.Int32)]);
}
else
{
spec = new DataQuerySpec(
FilteredSql,
[
new DataQueryParameter("search_term", escapedQuery, DbType.String),
new DataQueryParameter("row_limit", rowLimit, DbType.Int32)
]);
}
spec.ValidateFor(DataSourceKind.Oracle);
return spec;
}
}
internal static class TradingHaltQueries
{
private const string KospiAllSql = """
SELECT STOCK_CODE, STOCK_NAME
FROM (
SELECT b.F_STOCK_CODE AS STOCK_CODE, b.F_STOCK_WANNAME AS STOCK_NAME
FROM T_STOP_ONLINE1 a
INNER JOIN T_STOCK b ON a.F_STOCK_CODE = b.F_STOCK_CODE
WHERE b.F_MKT_HALT = 'Y'
ORDER BY UPPER(b.F_STOCK_WANNAME), b.F_STOCK_CODE
)
WHERE ROWNUM <= :row_limit
""";
private const string KospiFilteredSql = """
SELECT STOCK_CODE, STOCK_NAME
FROM (
SELECT b.F_STOCK_CODE AS STOCK_CODE, b.F_STOCK_WANNAME AS STOCK_NAME
FROM T_STOP_ONLINE1 a
INNER JOIN T_STOCK b ON a.F_STOCK_CODE = b.F_STOCK_CODE
WHERE b.F_MKT_HALT = 'Y'
AND UPPER(b.F_STOCK_WANNAME) LIKE '%' || :search_term || '%' ESCAPE '!'
ORDER BY UPPER(b.F_STOCK_WANNAME), b.F_STOCK_CODE
)
WHERE ROWNUM <= :row_limit
""";
private const string KosdaqAllSql = """
SELECT STOCK_CODE, STOCK_NAME
FROM (
SELECT b.F_STOCK_CODE AS STOCK_CODE, b.F_STOCK_WANNAME AS STOCK_NAME
FROM T_STOP_KOSDAQ_ONLINE1 a
INNER JOIN T_KOSDAQ_STOCK b ON a.F_STOCK_CODE = b.F_STOCK_CODE
WHERE b.F_MKT_HALT = 'Y'
ORDER BY UPPER(b.F_STOCK_WANNAME), b.F_STOCK_CODE
)
WHERE ROWNUM <= :row_limit
""";
private const string KosdaqFilteredSql = """
SELECT STOCK_CODE, STOCK_NAME
FROM (
SELECT b.F_STOCK_CODE AS STOCK_CODE, b.F_STOCK_WANNAME AS STOCK_NAME
FROM T_STOP_KOSDAQ_ONLINE1 a
INNER JOIN T_KOSDAQ_STOCK b ON a.F_STOCK_CODE = b.F_STOCK_CODE
WHERE b.F_MKT_HALT = 'Y'
AND UPPER(b.F_STOCK_WANNAME) LIKE '%' || :search_term || '%' ESCAPE '!'
ORDER BY UPPER(b.F_STOCK_WANNAME), b.F_STOCK_CODE
)
WHERE ROWNUM <= :row_limit
""";
internal static IReadOnlyList<TradingHaltQueryProfile> All { get; } =
[
new(
TradingHaltMarket.Kospi,
"TRADING_HALT_KOSPI",
KospiAllSql,
KospiFilteredSql),
new(
TradingHaltMarket.Kosdaq,
"TRADING_HALT_KOSDAQ",
KosdaqAllSql,
KosdaqFilteredSql)
];
}

View File

@@ -0,0 +1,228 @@
#nullable enable
using System.Data;
using System.Globalization;
namespace MMoneyCoderSharp.Data;
public sealed record WorldStockSearchItem(
string InputName,
string Symbol,
string NationCode);
public sealed record WorldStockSearchResult(
string Query,
DateTimeOffset RetrievedAt,
IReadOnlyList<WorldStockSearchItem> Items,
bool IsTruncated);
public interface IWorldStockSearchService
{
Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default);
}
public sealed class WorldStockSearchDataException : Exception
{
public WorldStockSearchDataException(string message)
: base(message)
{
}
}
/// <summary>
/// Searches the Oracle world-equity master used by the legacy overseas-stock
/// comparison workflow. Input is bound and LIKE metacharacters are escaped;
/// database output is accepted only when it exactly matches the required
/// three-column identity contract.
/// </summary>
public sealed class LegacyWorldStockSearchService : IWorldStockSearchService
{
public const int DefaultMaximumResults = 100;
public const int MaximumResults = 500;
public const int MaximumQueryLength = 64;
private const int MaximumInputNameLength = 200;
private const int MaximumSymbolLength = 64;
private const string InputNameColumn = "F_INPUT_NAME";
private const string SymbolColumn = "F_SYMB";
private const string NationCodeColumn = "F_NATC";
private const string QueryName = "WORLD_STOCK_SEARCH";
private const string SearchSql = """
SELECT F_INPUT_NAME, F_SYMB, F_NATC
FROM (
SELECT F_INPUT_NAME, F_SYMB, F_NATC
FROM T_WORLD_IX_EQ_MASTER
WHERE F_FDTC = '1'
AND F_NATC IN ('US', 'TW')
AND UPPER(F_INPUT_NAME) LIKE '%' || :search_term || '%' ESCAPE '!'
ORDER BY UPPER(F_INPUT_NAME), F_SYMB, F_NATC
)
WHERE ROWNUM <= :row_limit
""";
private readonly IDataQueryExecutor _executor;
public LegacyWorldStockSearchService(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var normalizedQuery = ValidateAndNormalizeQuery(query);
if (maximumResults is < 1 or > MaximumResults)
{
throw new ArgumentOutOfRangeException(
nameof(maximumResults),
$"The requested result limit must be between 1 and {MaximumResults}.");
}
cancellationToken.ThrowIfCancellationRequested();
var rowLimit = checked(maximumResults + 1);
var spec = CreateQuerySpec(
EscapeLikePattern(normalizedQuery.ToUpperInvariant()),
rowLimit);
var table = await _executor
.ExecuteAsync(DataSourceKind.Oracle, QueryName, spec, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var matches = ValidateRows(table, rowLimit);
return new WorldStockSearchResult(
normalizedQuery,
DateTimeOffset.Now,
matches.Take(maximumResults).ToArray(),
matches.Count > maximumResults);
}
private static DataQuerySpec CreateQuerySpec(string escapedQuery, int rowLimit)
{
var spec = new DataQuerySpec(
SearchSql,
[
new DataQueryParameter("search_term", escapedQuery, DbType.String),
new DataQueryParameter("row_limit", rowLimit, DbType.Int32)
]);
spec.ValidateFor(DataSourceKind.Oracle);
return spec;
}
private static string ValidateAndNormalizeQuery(string query)
{
ArgumentNullException.ThrowIfNull(query);
var normalized = query.Trim();
if (!IsSafeText(query) ||
normalized.Length > MaximumQueryLength ||
!IsSafeText(normalized))
{
throw new ArgumentException(
$"A world-stock search query must contain at most {MaximumQueryLength} visible characters.",
nameof(query));
}
return normalized;
}
private static string EscapeLikePattern(string value) => value
.Replace("!", "!!", StringComparison.Ordinal)
.Replace("%", "!%", StringComparison.Ordinal)
.Replace("_", "!_", StringComparison.Ordinal);
private static IReadOnlyList<WorldStockSearchItem> ValidateRows(
DataTable? table,
int rowLimit)
{
if (table is null ||
table.Columns.Count != 3 ||
!HasExactStringColumn(table.Columns[0], InputNameColumn) ||
!HasExactStringColumn(table.Columns[1], SymbolColumn) ||
!HasExactStringColumn(table.Columns[2], NationCodeColumn) ||
table.Rows.Count > rowLimit)
{
throw InvalidSchema();
}
var identities = new HashSet<WorldStockSearchItem>();
var inputNames = new HashSet<string>(StringComparer.Ordinal);
var matches = new List<WorldStockSearchItem>(table.Rows.Count);
foreach (DataRow row in table.Rows)
{
if (row[0] is not string inputName ||
row[1] is not string symbol ||
row[2] is not string nationCode)
{
throw InvalidSchema();
}
if (!IsCanonicalDatabaseValue(inputName, MaximumInputNameLength) ||
!IsCanonicalDatabaseValue(symbol, MaximumSymbolLength) ||
nationCode is not ("US" or "TW"))
{
throw new WorldStockSearchDataException(
$"World-stock search data from {QueryName} contains an invalid value.");
}
var item = new WorldStockSearchItem(inputName, symbol, nationCode);
if (!identities.Add(item))
{
throw new WorldStockSearchDataException(
$"World-stock search data from {QueryName} contains a duplicate identity.");
}
// The legacy comparison resolver selects the master row by
// F_INPUT_NAME alone. Even distinct symbols or nations are unsafe to
// expose when that lookup key would resolve ambiguously downstream.
if (!inputNames.Add(inputName))
{
throw new WorldStockSearchDataException(
$"World-stock search data from {QueryName} contains a duplicate F_INPUT_NAME lookup key.");
}
matches.Add(item);
}
return matches;
}
private static bool HasExactStringColumn(DataColumn column, string name) =>
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string);
private static bool IsCanonicalDatabaseValue(string value, int maximumLength) =>
value.Length is > 0 &&
value.Length <= maximumLength &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
IsSafeText(value);
private static bool IsSafeText(string value)
{
for (var index = 0; index < value.Length; index++)
{
var character = value[index];
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 static WorldStockSearchDataException InvalidSchema() =>
new($"World-stock search data from {QueryName} does not match the required schema.");
}

View File

@@ -0,0 +1,50 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.Core.Playout;
/// <summary>
/// Immutable observation of every native-runtime condition that must be idle
/// before an operator-data mutation can start.
/// </summary>
public sealed record OperatorMutationGateSnapshot(
bool IsFamilyWriteQuarantined,
bool IsGlobalWriteQuarantined,
bool IsLifetimeCancellationRequested,
bool IsPlayoutCommandInFlight,
bool IsPlayoutShutdownStarted,
bool IsBrowserCorrelationQuarantined,
bool IsEngineAvailable,
bool IsWorkflowAvailable,
bool IsCommandAvailable,
bool IsPlayCompletionPending,
string? PreparedSceneName,
string? OnAirSceneName,
string? PreparedCutCode,
string? OnAirCutCode,
bool IsRefreshActive,
bool IsRefreshTaskRunning);
/// <summary>
/// Pure fail-closed evaluator for the operator-data mutation boundary.
/// </summary>
public static class OperatorMutationGate
{
public static bool CanStart(OperatorMutationGateSnapshot? snapshot) =>
snapshot is not null &&
!snapshot.IsFamilyWriteQuarantined &&
!snapshot.IsGlobalWriteQuarantined &&
!snapshot.IsLifetimeCancellationRequested &&
!snapshot.IsPlayoutCommandInFlight &&
!snapshot.IsPlayoutShutdownStarted &&
!snapshot.IsBrowserCorrelationQuarantined &&
snapshot.IsEngineAvailable &&
snapshot.IsWorkflowAvailable &&
snapshot.IsCommandAvailable &&
!snapshot.IsPlayCompletionPending &&
string.IsNullOrWhiteSpace(snapshot.PreparedSceneName) &&
string.IsNullOrWhiteSpace(snapshot.OnAirSceneName) &&
string.IsNullOrWhiteSpace(snapshot.PreparedCutCode) &&
string.IsNullOrWhiteSpace(snapshot.OnAirCutCode) &&
!snapshot.IsRefreshActive &&
!snapshot.IsRefreshTaskRunning;
}

View File

@@ -35,6 +35,15 @@ public sealed record PlayoutBridgeTimeoutQuarantineParseResult(
PlayoutBridgeTimeoutQuarantine Request,
string Error);
public sealed record PlayoutBridgePagePlanRequest(
string RequestId,
IReadOnlyList<LegacyPlaylistEntry>? Entries);
public sealed record PlayoutBridgePagePlanRequestParseResult(
bool IsValid,
PlayoutBridgePagePlanRequest Request,
string Error);
/// <summary>
/// Strict, COM-neutral parser for the local WebView playout command boundary.
/// Presentation text is intentionally not accepted as scene mutation data.
@@ -169,6 +178,36 @@ public static class PlayoutBridgeProtocol
-1));
}
/// <summary>
/// Parses the read-only PageN preflight boundary. It accepts only the three paged
/// cut aliases and never accepts a stored page number, cue, mutation, or asset path.
/// </summary>
public static PlayoutBridgePagePlanRequestParseResult ParsePagePlanRequest(
JsonElement payload)
{
var empty = new PlayoutBridgePagePlanRequest(string.Empty, null);
if (payload.ValueKind != JsonValueKind.Object)
{
return InvalidPagePlan(empty, "The playlist page-plan request is invalid.");
}
if (!TryGetSafeToken(payload, "requestId", MaximumRequestIdLength, out var requestId))
{
return InvalidPagePlan(empty, "The playlist page-plan request identifier is invalid.");
}
var correlated = new PlayoutBridgePagePlanRequest(requestId, null);
if (!HasOnlyProperties(payload, "requestId", "entries") ||
!payload.TryGetProperty("entries", out var entriesElement) ||
!TryParsePlaylist(entriesElement, out var entries) ||
entries!.Any(entry => entry.CutCode is not ("5074" or "5077" or "5088")))
{
return InvalidPagePlan(correlated, "The playlist page-plan entries are invalid.");
}
return ValidPagePlan(new PlayoutBridgePagePlanRequest(requestId, entries));
}
/// <summary>
/// Parses the browser watchdog notification. A valid notification permanently
/// quarantines the native playout runtime for the remainder of the app process;
@@ -371,4 +410,11 @@ public static class PlayoutBridgeProtocol
private static PlayoutBridgeTimeoutQuarantineParseResult InvalidTimeoutQuarantine(
PlayoutBridgeTimeoutQuarantine request,
string error) => new(false, request, error);
private static PlayoutBridgePagePlanRequestParseResult ValidPagePlan(
PlayoutBridgePagePlanRequest request) => new(true, request, string.Empty);
private static PlayoutBridgePagePlanRequestParseResult InvalidPagePlan(
PlayoutBridgePagePlanRequest request,
string error) => new(false, request, error);
}

View File

@@ -16,7 +16,9 @@ public sealed record S8010SceneDataRequest(
CandleChartMode ChartMode,
string? InstrumentName,
bool ShowMovingAverage5,
bool ShowMovingAverage20);
bool ShowMovingAverage20,
string? ForeignSymbol = null,
string? ForeignNationCode = null);
/// <summary>
/// Loads the two legacy s8010 result sets (current quote, then ordered candles) through
@@ -91,7 +93,9 @@ internal static class S8010CandleQueryFactory
var requiresSelection = RequiresSelection(request.MarketTarget);
if (!requiresSelection)
{
if (!string.IsNullOrWhiteSpace(request.InstrumentName))
if (!string.IsNullOrWhiteSpace(request.InstrumentName) ||
request.ForeignSymbol is not null ||
request.ForeignNationCode is not null)
{
throw InvalidRequest();
}
@@ -107,6 +111,33 @@ internal static class S8010CandleQueryFactory
throw InvalidRequest();
}
var isForeign = request.MarketTarget is
CandleMarketTarget.OverseasIndex or CandleMarketTarget.OverseasStock;
if (!isForeign)
{
if (request.ForeignSymbol is not null || request.ForeignNationCode is not null)
{
throw InvalidRequest();
}
return selection;
}
var symbol = LegacySceneBuilderGuard.Text(
request.ForeignSymbol,
nameof(request.ForeignSymbol));
var nationCode = LegacySceneBuilderGuard.Text(
request.ForeignNationCode,
nameof(request.ForeignNationCode));
if (symbol.Length > 64 ||
nationCode.Length != 2 ||
nationCode.Any(character => character is < 'A' or > 'Z') ||
request.MarketTarget == CandleMarketTarget.OverseasStock &&
nationCode is not ("US" or "TW"))
{
throw InvalidRequest();
}
return selection;
}
@@ -187,11 +218,15 @@ internal static class S8010CandleQueryFactory
CandleMarketTarget.OverseasIndex => Foreign(
maximumRows,
selection!,
"0"),
request.ForeignSymbol!,
request.ForeignNationCode!,
ForeignInstrumentScope.GlobalIndex),
CandleMarketTarget.OverseasStock => Foreign(
maximumRows,
selection!,
"1"),
request.ForeignSymbol!,
request.ForeignNationCode!,
ForeignInstrumentScope.UsOrTaiwanStock),
CandleMarketTarget.KospiStock => Stock(
request.ChartMode,
isIntraday,
@@ -518,9 +553,21 @@ internal static class S8010CandleQueryFactory
private static QueryPair Foreign(
int maximumRows,
string selection,
string instrumentType)
string symbol,
string nationCode,
ForeignInstrumentScope scope)
{
const string currentSql = """
var (instrumentType, masterScope) = scope switch
{
ForeignInstrumentScope.GlobalIndex => (
"0",
"AND a.f_fdtc = '0'"),
ForeignInstrumentScope.UsOrTaiwanStock => (
"1",
"AND a.f_fdtc = '1' AND a.f_natc IN ('US', 'TW')"),
_ => throw InvalidRequest()
};
var currentSql = $"""
SELECT DISTINCT
a.f_input_name DISPLAY_NAME,
ROUND(b.f_last, 2) CURRENT_PRICE,
@@ -530,13 +577,18 @@ internal static class S8010CandleQueryFactory
FROM t_world_ix_eq_master a, t_world_ix_eq_sise b
WHERE a.f_symb = b.f_symb
AND a.f_input_name = :selection
AND a.f_symb = :symbol
AND a.f_natc = :nationCode
{masterScope}
AND b.f_fdtc = :instrumentType
""";
var current = Oracle(
currentSql,
new DataQueryParameter("selection", selection, DbType.String),
new DataQueryParameter("symbol", symbol, DbType.String),
new DataQueryParameter("nationCode", nationCode, DbType.String),
new DataQueryParameter("instrumentType", instrumentType, DbType.String));
const string rowsSql = """
var rowsSql = $"""
SELECT
TRADING_DATE, TRADING_TIME,
OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE,
@@ -555,6 +607,9 @@ internal static class S8010CandleQueryFactory
FROM t_world_ix_eq_master a, t_world_ix_eq_his b
WHERE a.f_symb = b.f_symb
AND a.f_input_name = :selection
AND a.f_symb = :symbol
AND a.f_natc = :nationCode
{masterScope}
AND b.f_fdtc = :instrumentType
ORDER BY b.F_xYMD DESC
)
@@ -564,6 +619,8 @@ internal static class S8010CandleQueryFactory
var rows = Oracle(
rowsSql,
new DataQueryParameter("selection", selection, DbType.String),
new DataQueryParameter("symbol", symbol, DbType.String),
new DataQueryParameter("nationCode", nationCode, DbType.String),
new DataQueryParameter("instrumentType", instrumentType, DbType.String),
new DataQueryParameter("rowLimit", maximumRows, DbType.Int32));
return new QueryPair(current, rows);
@@ -779,6 +836,12 @@ internal static class S8010CandleQueryFactory
string MasterTable,
string BatchTable);
private enum ForeignInstrumentScope
{
GlobalIndex,
UsOrTaiwanStock
}
private sealed record QueryPair(DataQuerySpec Current, DataQuerySpec Rows);
}

View File

@@ -123,22 +123,27 @@ public sealed class ComparisonAndYieldLegacyRequestResolver
}
var groupCode = selection.GroupCode?.Trim() ?? string.Empty;
if (subject == "지수")
if (subject is "지수" or "INDEX")
{
var index = groupCode switch
{
"코스피" => YieldGraphInstrumentKind.KospiIndex,
"코스닥" => YieldGraphInstrumentKind.KosdaqIndex,
"코스피200" => YieldGraphInstrumentKind.Kospi200Index,
"KRX100" => YieldGraphInstrumentKind.Krx100Index,
"코스피" or "KOSPI" or "KOSPI_INDEX" =>
YieldGraphInstrumentKind.KospiIndex,
"코스닥" or "KOSDAQ" or "KOSDAQ_INDEX" =>
YieldGraphInstrumentKind.KosdaqIndex,
"코스피200" or "KOSPI200" or "KOSPI200_INDEX" =>
YieldGraphInstrumentKind.Kospi200Index,
"KRX100" or "KRX100_INDEX" => YieldGraphInstrumentKind.Krx100Index,
_ => throw InvalidSelection()
};
return new YieldSceneLoadRequest(period, new YieldGraphInstrument(index));
}
if (groupCode.Contains("업종", StringComparison.Ordinal))
if (groupCode.Contains("업종", StringComparison.Ordinal) ||
groupCode.StartsWith("INDUSTRY_", StringComparison.Ordinal))
{
var industry = groupCode.Contains("코스피", StringComparison.Ordinal)
var industry = groupCode.Contains("코스피", StringComparison.Ordinal) ||
groupCode.Contains("KOSPI", StringComparison.Ordinal)
? YieldGraphInstrumentKind.KospiIndustry
: YieldGraphInstrumentKind.KosdaqIndustry;
return new YieldSceneLoadRequest(
@@ -256,8 +261,16 @@ public sealed class ComparisonAndYieldLegacyRequestResolver
};
}
private static ComparisonGraphPeriod ParseComparisonPeriod(string value) =>
Required(value) switch
private static ComparisonGraphPeriod ParseComparisonPeriod(string value)
{
var required = Required(value);
if (Enum.TryParse<ComparisonGraphPeriod>(required, ignoreCase: false, out var canonical) &&
Enum.IsDefined(canonical))
{
return canonical;
}
return required switch
{
"일봉" => ComparisonGraphPeriod.Daily,
"5일" => ComparisonGraphPeriod.FiveDays,
@@ -267,9 +280,18 @@ public sealed class ComparisonAndYieldLegacyRequestResolver
"12개월" => ComparisonGraphPeriod.TwelveMonths,
_ => throw InvalidSelection()
};
}
private static YieldGraphPeriod ParseYieldPeriod(string value) =>
Required(value) switch
private static YieldGraphPeriod ParseYieldPeriod(string value)
{
var required = Required(value);
if (Enum.TryParse<YieldGraphPeriod>(required, ignoreCase: false, out var canonical) &&
Enum.IsDefined(canonical))
{
return canonical;
}
return required switch
{
"5일" => YieldGraphPeriod.FiveDays,
"20일" => YieldGraphPeriod.TwentyDays,
@@ -278,9 +300,23 @@ public sealed class ComparisonAndYieldLegacyRequestResolver
"240일" => YieldGraphPeriod.TwoHundredFortyDays,
_ => throw InvalidSelection()
};
}
private static YieldGraphInstrumentKind? TryParseFx(string subject)
{
var canonical = subject switch
{
nameof(YieldGraphInstrumentKind.WonDollar) => YieldGraphInstrumentKind.WonDollar,
nameof(YieldGraphInstrumentKind.WonYen) => YieldGraphInstrumentKind.WonYen,
nameof(YieldGraphInstrumentKind.WonYuan) => YieldGraphInstrumentKind.WonYuan,
nameof(YieldGraphInstrumentKind.WonEuro) => YieldGraphInstrumentKind.WonEuro,
_ => (YieldGraphInstrumentKind?)null
};
if (canonical.HasValue)
{
return canonical;
}
if (subject.Contains("원달러", StringComparison.Ordinal))
{
return YieldGraphInstrumentKind.WonDollar;

View File

@@ -133,6 +133,9 @@ public sealed class LegacyParameterizedSceneRequestResolver
subject.Contains("FUTURES", StringComparison.Ordinal))
{
var values = SplitPair(subject, ',');
// Keep this branch closed to the market targets that s5032 renders with
// its index/futures column contract. The original accepted a stock name
// here but then read the stock name as a numeric price and failed.
var first = ParseMarketTarget(values[0]);
var second = ParseMarketTarget(values[1]);
var firstIsFutures = first == LegacyMarketQuoteTarget.Futures;
@@ -164,11 +167,20 @@ public sealed class LegacyParameterizedSceneRequestResolver
if (firstIsTarget != secondIsTarget)
{
var stockIndex = firstIsTarget ? 1 : 0;
if (expected && pair[stockIndex].Contains("(NXT)", StringComparison.Ordinal))
{
throw new LegacySceneDataException(
"Expected stock data is unavailable for NXT selections.");
}
var stock = await ResolveCurrentStockAsync(
pair[stockIndex],
// The original mixed branch has a domestic/NXT stock column shape.
// Its world fallback returns a different five-column shape which
// SetDataText cannot render, so world remains a stock-pair-only case.
allowWorld: false,
cancellationToken).ConfigureAwait(false);
var domesticMarket = ToKrxMarket(stock.Market);
var domesticMarket = ToDomesticMarket(stock.Market, allowNxt: !expected);
return new LegacyS8018LoadRequest(
new S8018MixedMarketAndStockLoadRequest(
stockIndex == 0 ? S8018PairSide.First : S8018PairSide.Second,
@@ -329,7 +341,9 @@ public sealed class LegacyParameterizedSceneRequestResolver
rawName,
allowWorld: false,
cancellationToken).ConfigureAwait(false);
return new DomesticStockSelection(ToKrxMarket(current.Market), current.StockName);
return new DomesticStockSelection(
ToDomesticMarket(current.Market, allowNxt: false),
current.StockName);
}
private async Task<bool> StockExistsAsync(
@@ -413,7 +427,9 @@ public sealed class LegacyParameterizedSceneRequestResolver
"SCENE_RESOLVE_WORLD_STOCK",
new DataQuerySpec(
"SELECT COUNT(*) MATCH_COUNT FROM T_WORLD_IX_EQ_MASTER " +
"WHERE F_KNAM = :stockName",
"WHERE F_INPUT_NAME = :stockName " +
"AND F_FDTC = '1' " +
"AND F_NATC IN ('US', 'TW')",
[parameter])),
_ => throw new LegacySceneDataException("The stock-master lookup is invalid.")
};
@@ -427,13 +443,22 @@ public sealed class LegacyParameterizedSceneRequestResolver
: S8018SessionPhase.BeforeOrAtKrxClose;
}
private static LegacyDomesticEquityMarket ToKrxMarket(S8018StockMarket market) => market switch
private static LegacyDomesticEquityMarket ToDomesticMarket(
S8018StockMarket market,
bool allowNxt)
{
S8018StockMarket.Kospi => LegacyDomesticEquityMarket.Kospi,
S8018StockMarket.Kosdaq => LegacyDomesticEquityMarket.Kosdaq,
_ => throw new LegacySceneDataException(
"The selected scene branch supports only KRX stocks.")
};
var result = market switch
{
S8018StockMarket.Kospi => LegacyDomesticEquityMarket.Kospi,
S8018StockMarket.Kosdaq => LegacyDomesticEquityMarket.Kosdaq,
S8018StockMarket.NxtKospi when allowNxt => LegacyDomesticEquityMarket.NxtKospi,
S8018StockMarket.NxtKosdaq when allowNxt => LegacyDomesticEquityMarket.NxtKosdaq,
_ => throw new LegacySceneDataException(
"The selected scene branch supports only the permitted domestic stock markets.")
};
return result;
}
private static LegacySceneSelection RequireSelection(LegacyPlaylistEntry entry) =>
entry.Selection ?? throw new LegacySceneDataException(

View File

@@ -40,6 +40,34 @@ public interface ILegacySceneCueProvider
CancellationToken cancellationToken = default);
}
/// <summary>
/// Read-only page-zero DTO boundary used to calculate PageN before PREPARE. Implementations
/// must not build a cue, dispatch an engine command, or touch COM/vendor objects.
/// </summary>
public interface ILegacyScenePagePlanProvider
{
Task<LegacyScenePagePlan> CreatePagePlanAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default);
}
public sealed record LegacyScenePagePlan(
string BuilderKey,
int ItemCount,
ScenePageSize PageSize,
int PageCount,
int AccessibleItemCount,
bool IsTruncated);
public sealed record LegacyPlaylistPagePlan(
string EntryId,
int ItemCount,
int PageSize,
int PageCount,
int AccessibleItemCount,
bool IsTruncated,
string BuilderKey);
public enum LegacyWorkflowNextKind
{
None,
@@ -73,6 +101,7 @@ public sealed class LegacyPlayoutWorkflow
private readonly IPlayoutEngine _engine;
private readonly ILegacySceneCueProvider _cueProvider;
private readonly ILegacyScenePagePlanProvider? _pagePlanProvider;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly object _engineStatusSync = new();
private IReadOnlyList<LegacyPlaylistEntry> _playlist = [];
@@ -87,11 +116,80 @@ public sealed class LegacyPlayoutWorkflow
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_cueProvider = cueProvider ?? throw new ArgumentNullException(nameof(cueProvider));
_pagePlanProvider = cueProvider as ILegacyScenePagePlanProvider;
State = EmptyState;
}
public LegacyPlayoutWorkflowState State { get; private set; }
/// <summary>
/// Loads only trusted page-zero database DTOs and returns bounded PageN plans. The
/// workflow gate makes this mutually exclusive with PREPARE/TAKE IN/NEXT/TAKE OUT,
/// but no IPlayoutEngine method is called and workflow state is not changed.
/// </summary>
public async Task<IReadOnlyList<LegacyPlaylistPagePlan>> PreflightPagePlansAsync(
IReadOnlyList<LegacyPlaylistEntry> entries,
CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_prepared is not null || _onAir is not null)
{
throw new InvalidOperationException(
"Playlist page plans can be queried only while the playout workflow is idle.");
}
var provider = _pagePlanProvider ?? throw new InvalidOperationException(
"The legacy scene provider does not expose read-only page preflight.");
var snapshot = ValidatePlaylistEntries(entries, nameof(entries));
var results = new LegacyPlaylistPagePlan[snapshot.Count];
for (var index = 0; index < snapshot.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var entry = snapshot[index];
var plan = await provider.CreatePagePlanAsync(entry, cancellationToken)
.ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(plan);
if (plan.ItemCount < 0 ||
plan.PageSize is not (ScenePageSize.Five or ScenePageSize.Six or ScenePageSize.Twelve) ||
plan.PageCount is < 0 or > ScenePaging.MaximumPageCount ||
plan.AccessibleItemCount < 0 ||
plan.AccessibleItemCount > plan.ItemCount ||
plan.IsTruncated != (plan.AccessibleItemCount < plan.ItemCount) ||
string.IsNullOrWhiteSpace(plan.BuilderKey))
{
throw new LegacySceneDataException(
"The scene provider returned an invalid page preflight plan.");
}
var expected = ScenePaging.CreatePlan(plan.ItemCount, plan.PageSize);
if (!expected.IsValid || expected.Plan is null ||
expected.Plan.TotalPages != plan.PageCount ||
expected.Plan.AccessibleItemCount != plan.AccessibleItemCount)
{
throw new LegacySceneDataException(
"The scene provider page preflight does not match PageN rules.");
}
results[index] = new LegacyPlaylistPagePlan(
entry.EntryId,
plan.ItemCount,
(int)plan.PageSize,
plan.PageCount,
plan.AccessibleItemCount,
plan.IsTruncated,
plan.BuilderKey);
}
return Array.AsReadOnly(results);
}
finally
{
_gate.Release();
}
}
/// <summary>
/// Reconciles asynchronous OnCutOut/OnStopAll/disconnect status with the native
/// playlist state. It never dispatches an engine command. If a command currently
@@ -433,12 +531,24 @@ public sealed class LegacyPlayoutWorkflow
IReadOnlyList<LegacyPlaylistEntry>? playlist,
int cueIndexZeroBased)
{
if (playlist is null || playlist.Count is < 1 or > MaximumPlaylistItems ||
cueIndexZeroBased < 0 || cueIndexZeroBased >= playlist.Count)
var entries = ValidatePlaylistEntries(playlist, nameof(playlist));
if (cueIndexZeroBased < 0 || cueIndexZeroBased >= entries.Count)
{
throw new ArgumentException("The playlist selection is invalid.", nameof(playlist));
}
return entries;
}
private static IReadOnlyList<LegacyPlaylistEntry> ValidatePlaylistEntries(
IReadOnlyList<LegacyPlaylistEntry>? playlist,
string parameterName)
{
if (playlist is null || playlist.Count is < 1 or > MaximumPlaylistItems)
{
throw new ArgumentException("The playlist selection is invalid.", parameterName);
}
var entries = playlist.ToArray();
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in entries)
@@ -448,7 +558,7 @@ public sealed class LegacyPlayoutWorkflow
entry.FadeDuration is < 0 or > 60 ||
!IsSafeSelection(entry.Selection))
{
throw new ArgumentException("The playlist contains an invalid entry.", nameof(playlist));
throw new ArgumentException("The playlist contains an invalid entry.", parameterName);
}
}

View File

@@ -9,6 +9,10 @@ public delegate Task<LegacySceneDataPage> LegacySceneDataLoaderDelegate(
int pageIndexZeroBased,
CancellationToken cancellationToken);
public delegate Task<LegacySceneDataPage> LegacyScenePagePlanDataLoaderDelegate(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken);
/// <summary>
/// Native-only route from one or more closed cut aliases to a parameterized DTO loader.
/// The delegate is composed in managed code; it is never selected by a Web-supplied type,
@@ -18,10 +22,12 @@ public sealed class LegacySceneDataSourceRoute
{
public LegacySceneDataSourceRoute(
IEnumerable<string> cutCodes,
LegacySceneDataLoaderDelegate loader)
LegacySceneDataLoaderDelegate loader,
LegacyScenePagePlanDataLoaderDelegate? pagePlanLoader = null)
{
ArgumentNullException.ThrowIfNull(cutCodes);
Loader = loader ?? throw new ArgumentNullException(nameof(loader));
PagePlanLoader = pagePlanLoader;
var codes = cutCodes.Select(ValidateCutCode).ToArray();
if (codes.Length == 0 ||
codes.Distinct(StringComparer.Ordinal).Count() != codes.Length)
@@ -36,6 +42,8 @@ public sealed class LegacySceneDataSourceRoute
internal LegacySceneDataLoaderDelegate Loader { get; }
internal LegacyScenePagePlanDataLoaderDelegate? PagePlanLoader { get; }
private static string ValidateCutCode(string value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 64 ||
@@ -52,14 +60,19 @@ public sealed class LegacySceneDataSourceRoute
/// Immutable dispatcher used by the app composition root to join scene-specific DB loaders.
/// Unknown cuts fail closed before any query is issued.
/// </summary>
public sealed class LegacySceneDataSourceRouter : ILegacySceneDataSource
public sealed class LegacySceneDataSourceRouter :
ILegacySceneDataSource,
ILegacyScenePagePlanDataSource
{
private readonly IReadOnlyDictionary<string, LegacySceneDataLoaderDelegate> _loaders;
private readonly IReadOnlyDictionary<string, LegacyScenePagePlanDataLoaderDelegate> _pagePlanLoaders;
public LegacySceneDataSourceRouter(IEnumerable<LegacySceneDataSourceRoute> routes)
{
ArgumentNullException.ThrowIfNull(routes);
var loaders = new Dictionary<string, LegacySceneDataLoaderDelegate>(StringComparer.Ordinal);
var pagePlanLoaders = new Dictionary<string, LegacyScenePagePlanDataLoaderDelegate>(
StringComparer.Ordinal);
foreach (var route in routes)
{
ArgumentNullException.ThrowIfNull(route);
@@ -71,6 +84,14 @@ public sealed class LegacySceneDataSourceRouter : ILegacySceneDataSource
"A cut code can have only one scene-data route.",
nameof(routes));
}
if (route.PagePlanLoader is not null &&
!pagePlanLoaders.TryAdd(code, route.PagePlanLoader))
{
throw new ArgumentException(
"A cut code can have only one scene page-plan route.",
nameof(routes));
}
}
}
@@ -80,6 +101,8 @@ public sealed class LegacySceneDataSourceRouter : ILegacySceneDataSource
}
_loaders = new ReadOnlyDictionary<string, LegacySceneDataLoaderDelegate>(loaders);
_pagePlanLoaders = new ReadOnlyDictionary<string, LegacyScenePagePlanDataLoaderDelegate>(
pagePlanLoaders);
CutCodes = Array.AsReadOnly(loaders.Keys.OrderBy(code => code, StringComparer.Ordinal).ToArray());
}
@@ -104,4 +127,18 @@ public sealed class LegacySceneDataSourceRouter : ILegacySceneDataSource
return loader(entry, pageIndexZeroBased, cancellationToken);
}
public Task<LegacySceneDataPage> LoadPagePlanDataAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
if (!_pagePlanLoaders.TryGetValue(entry.CutCode, out var loader))
{
throw new LegacySceneDataException(
"The selected cut does not have a registered page preflight route.");
}
return loader(entry, cancellationToken);
}
}

View File

@@ -89,6 +89,7 @@ public sealed record NxtThemePagedQuoteLoadRequest(
int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased);
public sealed record ExpertPagedQuoteLoadRequest(
string ExpertCode,
string ExpertName,
int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased);
@@ -112,6 +113,11 @@ public sealed class S5074SceneDataLoader
PagedQuoteLoadRequest request,
CancellationToken cancellationToken = default) =>
_core.LoadAsync(request, cancellationToken);
public Task<PagedQuoteSceneData> LoadPagePlanAsync(
PagedQuoteLoadRequest request,
CancellationToken cancellationToken = default) =>
_core.LoadAsync(request, cancellationToken, includeTruncationProbe: true);
}
public sealed class S5077SceneDataLoader
@@ -130,6 +136,11 @@ public sealed class S5077SceneDataLoader
PagedQuoteLoadRequest request,
CancellationToken cancellationToken = default) =>
_core.LoadAsync(request, cancellationToken);
public Task<PagedQuoteSceneData> LoadPagePlanAsync(
PagedQuoteLoadRequest request,
CancellationToken cancellationToken = default) =>
_core.LoadAsync(request, cancellationToken, includeTruncationProbe: true);
}
public sealed class S5088SceneDataLoader
@@ -148,6 +159,11 @@ public sealed class S5088SceneDataLoader
PagedQuoteLoadRequest request,
CancellationToken cancellationToken = default) =>
_core.LoadAsync(request, cancellationToken);
public Task<PagedQuoteSceneData> LoadPagePlanAsync(
PagedQuoteLoadRequest request,
CancellationToken cancellationToken = default) =>
_core.LoadAsync(request, cancellationToken, includeTruncationProbe: true);
}
internal sealed class PagedQuoteSceneDataLoaderCore
@@ -171,7 +187,8 @@ internal sealed class PagedQuoteSceneDataLoaderCore
public async Task<PagedQuoteSceneData> LoadAsync(
PagedQuoteLoadRequest request,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
bool includeTruncationProbe = false)
{
ArgumentNullException.ThrowIfNull(request);
if (request.PageNumberOneBased is < 1 or > ScenePaging.MaximumPageCount)
@@ -180,14 +197,32 @@ internal sealed class PagedQuoteSceneDataLoaderCore
"The paged quote request must select a page from 1 through 20.");
}
var maximumRows = checked((int)_pageSize * ScenePaging.MaximumPageCount);
var plan = PagedQuoteQueryFactory.Create(request, maximumRows);
var accessibleRows = checked((int)_pageSize * ScenePaging.MaximumPageCount);
var queryRows = includeTruncationProbe
? checked(accessibleRows + 1)
: accessibleRows;
var plan = PagedQuoteQueryFactory.Create(request, queryRows);
var table = await _executor.ExecuteAsync(
plan.Source,
_tableName,
plan.Query,
cancellationToken).ConfigureAwait(false);
var rows = MapRows(table, maximumRows, plan.HeaderStyle);
var rows = MapRows(
table,
queryRows,
plan.HeaderStyle,
allowEmpty: includeTruncationProbe);
if (includeTruncationProbe && rows.Count == 0)
{
return new PagedQuoteSceneData(
plan.Branch,
plan.HeaderStyle,
plan.TitleStyle,
plan.Session,
plan.Title,
rows,
request.PageNumberOneBased);
}
var window = ScenePaging.GetWindow(
rows.Count,
_pageSize,
@@ -211,9 +246,11 @@ internal sealed class PagedQuoteSceneDataLoaderCore
private static IReadOnlyList<PagedQuoteRowData> MapRows(
DataTable? table,
int maximumRows,
PagedQuoteHeaderStyle headerStyle)
PagedQuoteHeaderStyle headerStyle,
bool allowEmpty)
{
if (table is null || table.Rows.Count is < 1 || table.Rows.Count > maximumRows)
if (table is null || (!allowEmpty && table.Rows.Count < 1) ||
table.Rows.Count > maximumRows)
{
throw new LegacySceneDataException(
"The paged quote query returned an invalid row count.");
@@ -471,12 +508,19 @@ internal static class PagedQuoteQueryFactory
ExpertPagedQuoteLoadRequest request,
int maximumRows)
{
var expertCode = RequiredSelection(request.ExpertCode);
var expertName = RequiredSelection(request.ExpertName);
if (expertCode.Length != 4 || expertCode.Any(character => !char.IsAsciiDigit(character)))
{
throw InvalidRequest();
}
return new PagedQuoteQueryPlan(
DataSourceKind.Oracle,
new DataQuerySpec(
ExpertQuery,
[
new DataQueryParameter("expertCode", expertCode, DbType.String),
new DataQueryParameter("expertName", expertName, DbType.String),
new DataQueryParameter("maxRows", maximumRows, DbType.Int32)
]),
@@ -1030,7 +1074,8 @@ internal static class PagedQuoteQueryFactory
FROM EXPERT_LIST e
JOIN RECOMMEND_LIST r ON e.EP_CODE = r.RC_CODE
JOIN QUOTES q ON r.RC_STOCK_CODE = q.STOCK_CODE
WHERE e.EP_NAME = :expertName
WHERE e.EP_CODE = :expertCode
AND e.EP_NAME = :expertName
AND r.RC_BUY_AMOUNT <> 0
ORDER BY r.PLAYINDEX
)

View File

@@ -1085,7 +1085,7 @@ public sealed class S8018SceneMutationBuilder : ILegacySceneMutationBuilder<S801
private static S8018MixedStockQuote ValidateMixedStock(S8018MixedStockQuote? quote)
{
var value = LegacySceneBuilderGuard.NotNull(quote, nameof(quote));
S5001SceneMutationBuilder.RequireDomesticMarket(value.Market, allowNxt: false);
S5001SceneMutationBuilder.RequireDomesticMarket(value.Market, allowNxt: true);
var title = LegacySceneBuilderGuard.Text(value.Title, nameof(value.Title));
var direction = S5001SceneMutationBuilder.RequireDirection(value.Direction);
return value with { Title = title, Direction = direction };

View File

@@ -655,7 +655,9 @@ public sealed class S8018SceneDataLoader
S5001SceneMutationBuilder.RequireDefined(request.StockSide, nameof(request.StockSide));
S5001SceneMutationBuilder.RequireDefined(request.Mode, nameof(request.Mode));
ValidateMarketTarget(request.MarketTarget, request.Mode);
S5001SceneMutationBuilder.RequireDomesticMarket(request.StockMarket, allowNxt: false);
S5001SceneMutationBuilder.RequireDomesticMarket(
request.StockMarket,
allowNxt: request.Mode == S8018MarketDataMode.Current);
var stockName = ParameterizedSceneRowReader.Selector(request.StockName, nameof(request.StockName));
var expected = request.Mode == S8018MarketDataMode.Expected;
var stockQuery = expected

View File

@@ -431,6 +431,8 @@ internal static class ParameterizedMarketSceneQueries
FROM t_world_ix_eq_master a
JOIN t_world_ix_eq_sise b ON a.f_symb = b.f_symb
WHERE a.f_symb = :symbol
AND a.f_fdtc = '0'
AND b.f_fdtc = '0'
FETCH FIRST 2 ROWS ONLY
""";
@@ -485,6 +487,9 @@ internal static class ParameterizedMarketSceneQueries
FROM t_world_ix_eq_sise a
JOIN t_world_ix_eq_master b ON a.f_symb = b.f_symb
WHERE b.f_knam = :industryName
AND b.f_fdtc = '0'
AND b.f_natc = 'US'
AND a.f_fdtc = '0'
FETCH FIRST 2 ROWS ONLY
""";
@@ -497,6 +502,9 @@ internal static class ParameterizedMarketSceneQueries
FROM t_world_ix_eq_sise a
JOIN t_world_ix_eq_master b ON a.f_symb = b.f_symb
WHERE b.f_input_name = :stockName
AND b.f_fdtc = '1'
AND b.f_natc IN ('US', 'TW')
AND a.f_fdtc = '1'
FETCH FIRST 2 ROWS ONLY
""";

View File

@@ -27,6 +27,36 @@ public sealed record LegacySceneCueCompositionOptions(
BackgroundKind: LegacySceneBackgroundKind.None);
}
/// <summary>
/// Supplies an immutable composition snapshot for one cue build. Native operator controls
/// may replace the snapshot only while the playout workflow is idle; Web content never
/// supplies an asset path.
/// </summary>
public interface ILegacySceneCueCompositionOptionsSource
{
LegacySceneCueCompositionOptions Current { get; }
}
public sealed class MutableLegacySceneCueCompositionOptionsSource :
ILegacySceneCueCompositionOptionsSource
{
private LegacySceneCueCompositionOptions _current;
public MutableLegacySceneCueCompositionOptionsSource(
LegacySceneCueCompositionOptions initial)
{
_current = RegisteredLegacySceneCueProvider.ValidateOptions(initial);
}
public LegacySceneCueCompositionOptions Current => Volatile.Read(ref _current);
public void Update(LegacySceneCueCompositionOptions value)
{
var validated = RegisteredLegacySceneCueProvider.ValidateOptions(value);
Interlocked.Exchange(ref _current, validated);
}
}
/// <summary>
/// One trusted DTO produced by a scene-specific database loader. BuilderKey resolves
/// conditional aliases such as 5032/8018 without accepting K3D object or method names.
@@ -44,26 +74,54 @@ public interface ILegacySceneDataSource
CancellationToken cancellationToken = default);
}
/// <summary>
/// Optional read-only route that loads a bounded page-zero DTO with one extra row used
/// only to detect the 20-page truncation boundary. It never builds scene mutations.
/// </summary>
public interface ILegacyScenePagePlanDataSource
{
Task<LegacySceneDataPage> LoadPagePlanDataAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Converts a native DB DTO into a complete cue while preserving the legacy order:
/// load/effect/background, BeginTransaction, ordered builder mutations,
/// QueryVariables, EndTransaction, Prepare. The latter operations are performed by
/// IPlayoutEngine; this provider exposes no COM or reflection surface.
/// </summary>
public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider
public sealed class RegisteredLegacySceneCueProvider :
ILegacySceneCueProvider,
ILegacyScenePagePlanProvider
{
private readonly ILegacySceneDataSource _dataSource;
private readonly ILegacyScenePagePlanDataSource? _pagePlanDataSource;
private readonly LegacySceneMutationBuilderRegistry _builders;
private readonly LegacySceneCueCompositionOptions _options;
private readonly ILegacySceneCueCompositionOptionsSource _optionsSource;
public RegisteredLegacySceneCueProvider(
ILegacySceneDataSource dataSource,
LegacySceneMutationBuilderRegistry? builders = null,
LegacySceneCueCompositionOptions? options = null)
: this(
dataSource,
new FixedCompositionOptionsSource(
ValidateOptions(options ?? LegacySceneCueCompositionOptions.Default)),
builders)
{
}
public RegisteredLegacySceneCueProvider(
ILegacySceneDataSource dataSource,
ILegacySceneCueCompositionOptionsSource optionsSource,
LegacySceneMutationBuilderRegistry? builders = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_pagePlanDataSource = dataSource as ILegacyScenePagePlanDataSource;
_builders = builders ?? LegacySceneMutationBuilderRegistry.Default;
_options = ValidateOptions(options ?? LegacySceneCueCompositionOptions.Default);
_optionsSource = optionsSource ?? throw new ArgumentNullException(nameof(optionsSource));
_ = ValidateOptions(_optionsSource.Current);
}
public async Task<LegacySceneCuePage> CreatePageAsync(
@@ -77,6 +135,10 @@ public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider
throw new ArgumentOutOfRangeException(nameof(pageIndexZeroBased));
}
// Capture once before the asynchronous DB load. F2/F3 changes can affect only a
// later PREPARE and cannot alter a cue halfway through construction.
var options = ValidateOptions(_optionsSource.Current);
var loaded = await _dataSource.LoadPageAsync(
entry,
pageIndexZeroBased,
@@ -88,15 +150,7 @@ public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider
throw new LegacySceneDataException("A scene loader returned an invalid item count.");
}
var definition = LegacySceneCatalog.Definitions.SingleOrDefault(candidate =>
string.Equals(candidate.BuilderKey, loaded.BuilderKey, StringComparison.Ordinal));
if (definition is null ||
definition.Reachability == LegacySceneReachability.NoMainFormDispatch ||
!definition.CutAliases.Contains(entry.CutCode, StringComparer.Ordinal))
{
throw new LegacySceneDataException(
"The scene loader returned a builder that is not valid for the selected cut.");
}
var definition = RequireDefinition(entry, loaded);
var pageSize = ConvertPageSize(definition.PageSize);
if (pageSize is null && pageIndexZeroBased != 0)
@@ -119,14 +173,14 @@ public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider
var builderMutations = _builders.Build(definition.BuilderKey, loaded.Data);
var mutations = new List<PlayoutMutation>(builderMutations.Count + 2);
AddBackgroundMutations(mutations, _options);
AddBackgroundMutations(mutations, options);
mutations.AddRange(builderMutations);
return new LegacySceneCuePage(
new PlayoutCue(
entry.CutCode + ".t2s",
entry.CutCode,
FadeDuration: entry.FadeDuration ?? _options.FadeDuration,
FadeDuration: entry.FadeDuration ?? options.FadeDuration,
Mutations: Array.AsReadOnly(mutations.ToArray())),
loaded.ItemCount,
pageSize,
@@ -134,6 +188,58 @@ public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider
LegacyScenePreviewFactory.Create(Array.AsReadOnly(mutations.ToArray())));
}
public async Task<LegacyScenePagePlan> CreatePagePlanAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var source = _pagePlanDataSource ?? throw new InvalidOperationException(
"The registered scene data source does not expose page preflight data.");
var loaded = await source.LoadPagePlanDataAsync(entry, cancellationToken)
.ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(loaded);
ArgumentNullException.ThrowIfNull(loaded.Data);
if (loaded.ItemCount < 0)
{
throw new LegacySceneDataException("A scene page preflight returned an invalid item count.");
}
var definition = RequireDefinition(entry, loaded);
var pageSize = ConvertPageSize(definition.PageSize) ?? throw new LegacySceneDataException(
"Page preflight is available only for a 5-, 6-, or 12-row scene.");
var result = ScenePaging.CreatePlan(loaded.ItemCount, pageSize);
if (!result.IsValid || result.Plan is null)
{
throw new LegacySceneDataException("The scene page preflight returned invalid paging data.");
}
var plan = result.Plan;
return new LegacyScenePagePlan(
definition.BuilderKey,
plan.ItemCount,
plan.PageSize,
plan.TotalPages,
plan.AccessibleItemCount,
plan.ExcludedItemCount > 0);
}
private static LegacySceneDefinition RequireDefinition(
LegacyPlaylistEntry entry,
LegacySceneDataPage loaded)
{
var definition = LegacySceneCatalog.Definitions.SingleOrDefault(candidate =>
string.Equals(candidate.BuilderKey, loaded.BuilderKey, StringComparison.Ordinal));
if (definition is null ||
definition.Reachability == LegacySceneReachability.NoMainFormDispatch ||
!definition.CutAliases.Contains(entry.CutCode, StringComparer.Ordinal))
{
throw new LegacySceneDataException(
"The scene loader returned a builder that is not valid for the selected cut.");
}
return definition;
}
private static ScenePageSize? ConvertPageSize(LegacyScenePageSize value) => value switch
{
LegacyScenePageSize.None => null,
@@ -143,9 +249,10 @@ public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider
_ => throw new LegacySceneDataException("The scene catalog contains an invalid page size.")
};
private static LegacySceneCueCompositionOptions ValidateOptions(
internal static LegacySceneCueCompositionOptions ValidateOptions(
LegacySceneCueCompositionOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (options.FadeDuration is < 0 or > 60 ||
!Enum.IsDefined(options.BackgroundKind) ||
(options.BackgroundKind == LegacySceneBackgroundKind.Video &&
@@ -203,4 +310,10 @@ public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider
throw new InvalidOperationException("Unknown background kind.");
}
}
private sealed class FixedCompositionOptionsSource(
LegacySceneCueCompositionOptions current) : ILegacySceneCueCompositionOptionsSource
{
public LegacySceneCueCompositionOptions Current { get; } = current;
}
}

View File

@@ -0,0 +1,119 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
/// <summary>
/// A versioned, trusted projection of the operator-owned VI file. Browser playlist
/// payloads carry only <see cref="TrustedViManualReference.SubjectSentinel"/> and
/// <see cref="RowVersion"/>; stock names are resolved again inside the native boundary.
/// </summary>
public sealed record TrustedViStockNameSnapshot(
IReadOnlyList<string> StockNames,
string RowVersion);
public interface ITrustedViStockNameSnapshotSource
{
Task<TrustedViStockNameSnapshot> ReadStockNameSnapshotAsync(
CancellationToken cancellationToken = default);
}
public static class TrustedViManualReference
{
public const string SubjectSentinel = "@MBN_VI_SNAPSHOT_V1";
public const int RowVersionLength = 64;
public const int MaximumItemCount = 100;
public static bool IsRowVersion(string? value) =>
value is { Length: RowVersionLength } &&
value.All(character =>
character is >= '0' and <= '9' or >= 'a' and <= 'f');
/// <summary>
/// Resolves a PAGED_VI selection. Versioned selections fail closed unless the
/// current trusted snapshot is byte-for-byte the version selected by the operator.
/// Historical comma-name selections remain available only with an empty data code.
/// </summary>
public static async Task<ViPagedQuoteLoadRequest> ResolveAsync(
LegacySceneSelection selection,
int pageIndexZeroBased,
ITrustedViStockNameSnapshotSource? trustedSource,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(selection);
if (!string.Equals(selection.GroupCode, "PAGED_VI", StringComparison.Ordinal) ||
pageIndexZeroBased < 0)
{
throw new LegacySceneDataException("The VI paged selection is invalid.");
}
var page = checked(pageIndexZeroBased + 1);
if (string.Equals(selection.Subject, SubjectSentinel, StringComparison.Ordinal))
{
if (trustedSource is null || !IsRowVersion(selection.DataCode))
{
throw new LegacySceneDataException(
"The trusted VI snapshot reference is unavailable or invalid.");
}
var snapshot = await trustedSource.ReadStockNameSnapshotAsync(cancellationToken)
.ConfigureAwait(false);
if (!IsRowVersion(snapshot.RowVersion) ||
!string.Equals(
selection.DataCode,
snapshot.RowVersion,
StringComparison.Ordinal))
{
throw new LegacySceneDataException(
"The trusted VI snapshot changed after playlist creation.");
}
var names = ValidateTrustedNames(snapshot.StockNames);
return new ViPagedQuoteLoadRequest(names, page);
}
// A version is meaningful only with the exact reserved sentinel. This closes
// the downgrade path where a modified sentinel could otherwise be treated as
// one historical stock name.
if (!string.IsNullOrEmpty(selection.DataCode) ||
selection.Subject.StartsWith("@MBN_VI_SNAPSHOT_", StringComparison.Ordinal))
{
throw new LegacySceneDataException("The trusted VI snapshot reference is invalid.");
}
return new ViPagedQuoteLoadRequest(ParseHistoricalNames(selection.Subject), page);
}
private static IReadOnlyList<string> ValidateTrustedNames(IReadOnlyList<string>? values)
{
if (values is null || values.Count is < 1 or > MaximumItemCount)
{
throw new LegacySceneDataException("The trusted VI snapshot is empty or invalid.");
}
var names = values.ToArray();
if (names.Any(value =>
string.IsNullOrWhiteSpace(value) ||
value.Length > 200 ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
value.Contains(',') ||
value.Any(char.IsControl)))
{
throw new LegacySceneDataException("The trusted VI snapshot contains invalid data.");
}
return Array.AsReadOnly(names);
}
private static IReadOnlyList<string> ParseHistoricalNames(string value)
{
var names = value.Split(
',',
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (names.Length is < 1 or > 240 || names.Any(name => name.Any(char.IsControl)))
{
throw new LegacySceneDataException("The VI stock-name selection is invalid.");
}
return Array.AsReadOnly(names);
}
}

View File

@@ -0,0 +1,369 @@
#nullable enable
using System.Data;
using System.Data.Common;
using MMoneyCoderSharp.Data;
using MySqlConnector;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Executes the closed SB_LIST/SB_ITEM and EXPERT_LIST/RECOMMEND_LIST command
/// sets exactly once. Every operation uses one serializable transaction. There
/// is intentionally no transient retry path for state-changing commands.
/// </summary>
public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationExecutor
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
private readonly TimeSpan _operationTimeout;
private readonly Action<DataSourceKind, DbCommand> _configureCommand;
public OperatorCatalogMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector? errorDetector = null)
: this(
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureProviderCommand)
{
}
internal OperatorCatalogMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DataSourceKind, DbCommand> configureCommand)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
ArgumentNullException.ThrowIfNull(resilienceOptions);
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ??
throw new ArgumentNullException(nameof(configureCommand));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
_operationTimeout = TimeSpan.FromSeconds(resilienceOptions.OperationTimeoutSeconds);
}
public async Task<OperatorCatalogDbTransactionResult> ExecuteTransactionAsync(
OperatorCatalogDbTransaction transaction,
CancellationToken cancellationToken = default)
{
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
var commandTimeoutSeconds = _connectionFactory.GetCommandTimeoutSeconds(transaction.Source);
if (commandTimeoutSeconds is < 1 or > 600)
{
throw new DatabaseConfigurationException(
"The operator-catalog command timeout must be between 1 and 600 seconds.");
}
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);
var operationToken = timeoutSource.Token;
DbConnection? connection = null;
DbTransaction? databaseTransaction = null;
var commitStarted = false;
try
{
connection = _connectionFactory.Create(transaction.Source);
await connection.OpenAsync(operationToken).ConfigureAwait(false);
databaseTransaction = await connection.BeginTransactionAsync(
IsolationLevel.Serializable,
operationToken)
.ConfigureAwait(false);
var affectedRows = new List<int>(transaction.Commands.Count);
foreach (var commandSpec in transaction.Commands)
{
operationToken.ThrowIfCancellationRequested();
await using var command = connection.CreateCommand();
command.CommandText = commandSpec.Sql;
command.CommandType = CommandType.Text;
command.CommandTimeout = commandTimeoutSeconds;
command.Transaction = databaseTransaction;
_configureCommand(transaction.Source, command);
foreach (var parameterSpec in commandSpec.Parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = parameterSpec.Name;
parameter.Direction = ParameterDirection.Input;
parameter.DbType = parameterSpec.DbType;
parameter.Value = parameterSpec.Value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
// Execute exactly once and enforce the optimistic/uniqueness
// precondition before any later command can run.
var count = await command.ExecuteNonQueryAsync(operationToken)
.ConfigureAwait(false);
if (count < 0 ||
commandSpec.AffectedRowsRule ==
OperatorCatalogAffectedRowsRule.ExactlyOne && count != 1)
{
throw new AffectedRowsConflictException(commandSpec.Kind, count);
}
affectedRows.Add(count);
}
commitStarted = true;
await databaseTransaction.CommitAsync(operationToken).ConfigureAwait(false);
return new OperatorCatalogDbTransactionResult(
transaction.OperationId,
Array.AsReadOnly(affectedRows.ToArray()),
DateTimeOffset.UtcNow);
}
catch (Exception exception)
{
if (commitStarted)
{
// The provider may have committed even if the client observed a
// timeout or disconnect. Do not issue rollback or retry commands.
throw UnknownOutcome(transaction.Kind, exception);
}
if (databaseTransaction is null)
{
throw new OperatorCatalogMutationException(
$"The {transaction.Kind} transaction did not start and did not commit.",
outcomeUnknown: false,
exception);
}
var interrupted = exception is OperationCanceledException ||
_errorDetector.IsTimeout(transaction.Source, exception);
var rollbackSucceeded = await TryRollbackAsync(
databaseTransaction,
commandTimeoutSeconds)
.ConfigureAwait(false);
// Once a command is in flight, cancellation/timeout is treated as
// OutcomeUnknown even when rollback returned. This is deliberately
// conservative and prohibits an automatic retry.
if (interrupted || !rollbackSucceeded)
{
throw UnknownOutcome(transaction.Kind, exception);
}
if (exception is AffectedRowsConflictException conflict)
{
throw new OperatorCatalogConflictException(
transaction.Kind,
conflict.CommandKind,
exception);
}
throw new OperatorCatalogMutationException(
$"The {transaction.Kind} transaction was rolled back and did not commit.",
outcomeUnknown: false,
exception);
}
finally
{
await DisposeQuietlyAsync(databaseTransaction).ConfigureAwait(false);
await DisposeQuietlyAsync(connection).ConfigureAwait(false);
}
}
internal static void ConfigureProviderCommand(
DataSourceKind source,
DbCommand command)
{
ArgumentNullException.ThrowIfNull(command);
switch (source)
{
case DataSourceKind.Oracle when command is OracleCommand oracleCommand:
oracleCommand.BindByName = true;
break;
case DataSourceKind.MariaDb when command is MySqlCommand:
break;
case DataSourceKind.Oracle:
case DataSourceKind.MariaDb:
throw new DatabaseConfigurationException(
"The operator-catalog connection created the wrong provider command.");
default:
throw new ArgumentOutOfRangeException(nameof(source), source, null);
}
}
private static async Task<bool> TryRollbackAsync(
DbTransaction transaction,
int timeoutSeconds)
{
try
{
using var rollbackTimeout = new CancellationTokenSource(
TimeSpan.FromSeconds(timeoutSeconds));
await transaction.RollbackAsync(rollbackTimeout.Token).ConfigureAwait(false);
return true;
}
catch
{
return false;
}
}
private static async Task DisposeQuietlyAsync(IAsyncDisposable? disposable)
{
if (disposable is null)
{
return;
}
try
{
await disposable.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Commit/rollback outcome has already been classified. Disposal must
// not replace that evidence or trigger another database operation.
}
}
private static void ValidateTransaction(OperatorCatalogDbTransaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction);
if (transaction.OperationId == Guid.Empty ||
!Enum.IsDefined(transaction.Kind) ||
!Enum.IsDefined(transaction.Source) ||
transaction.Commands.Count == 0 ||
transaction.Commands.Any(static command => command is null))
{
throw new ArgumentException(
"The operator-catalog transaction is invalid.",
nameof(transaction));
}
var themeMutation = transaction.Kind is
OperatorCatalogMutationKind.CreateTheme or
OperatorCatalogMutationKind.ReplaceTheme or
OperatorCatalogMutationKind.DeleteTheme;
var identityLength = themeMutation ? 8 : 4;
if (transaction.IdentityCode.Length != identityLength ||
transaction.IdentityCode.Any(static character => !char.IsAsciiDigit(character)) ||
!themeMutation && transaction.Source != DataSourceKind.Oracle)
{
throw new ArgumentException(
"The operator-catalog transaction identity or source is invalid.",
nameof(transaction));
}
var commandKinds = transaction.Commands.Select(static command => command.Kind).ToArray();
var validShape = transaction.Kind switch
{
OperatorCatalogMutationKind.CreateTheme =>
commandKinds[0] == OperatorCatalogDbCommandKind.InsertTheme &&
commandKinds.Skip(1).All(static kind =>
kind == OperatorCatalogDbCommandKind.InsertThemeItem),
OperatorCatalogMutationKind.ReplaceTheme =>
commandKinds.Length >= 2 &&
commandKinds[0] == OperatorCatalogDbCommandKind.UpdateTheme &&
commandKinds[1] == OperatorCatalogDbCommandKind.DeleteThemeItems &&
commandKinds.Skip(2).All(static kind =>
kind == OperatorCatalogDbCommandKind.InsertThemeItem),
OperatorCatalogMutationKind.DeleteTheme =>
commandKinds.SequenceEqual(
[
OperatorCatalogDbCommandKind.DeleteThemeItems,
OperatorCatalogDbCommandKind.DeleteTheme
]),
OperatorCatalogMutationKind.CreateExpert =>
commandKinds[0] == OperatorCatalogDbCommandKind.InsertExpert &&
commandKinds.Skip(1).All(static kind =>
kind == OperatorCatalogDbCommandKind.InsertRecommendation),
OperatorCatalogMutationKind.ReplaceExpert =>
commandKinds.Length >= 2 &&
commandKinds[0] == OperatorCatalogDbCommandKind.UpdateExpert &&
commandKinds[1] == OperatorCatalogDbCommandKind.DeleteRecommendations &&
commandKinds.Skip(2).All(static kind =>
kind == OperatorCatalogDbCommandKind.InsertRecommendation),
OperatorCatalogMutationKind.DeleteExpert =>
commandKinds.SequenceEqual(
[
OperatorCatalogDbCommandKind.DeleteRecommendations,
OperatorCatalogDbCommandKind.DeleteExpert
]),
_ => false
};
if (!validShape)
{
throw new ArgumentException(
"The operator-catalog transaction command order is invalid.",
nameof(transaction));
}
var marker = transaction.Source == DataSourceKind.Oracle ? ':' : '@';
var identityParameterName = themeMutation ? "theme_code" : "expert_code";
foreach (var command in transaction.Commands)
{
if (string.IsNullOrWhiteSpace(command.Sql) ||
command.Sql.Contains(';', StringComparison.Ordinal) ||
!Enum.IsDefined(command.Kind) ||
!Enum.IsDefined(command.AffectedRowsRule) ||
command.Parameters.Count == 0 ||
command.Parameters.Any(static parameter => parameter is null))
{
throw new ArgumentException(
"An operator-catalog command is invalid.",
nameof(transaction));
}
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var parameter in command.Parameters)
{
if (!names.Add(parameter.Name) ||
command.Sql.IndexOf(
string.Concat(marker, parameter.Name),
StringComparison.OrdinalIgnoreCase) < 0)
{
throw new ArgumentException(
"An operator-catalog command parameter is invalid.",
nameof(transaction));
}
}
var identityParameter = command.Parameters.SingleOrDefault(parameter =>
string.Equals(
parameter.Name,
identityParameterName,
StringComparison.OrdinalIgnoreCase));
if (identityParameter?.Value is not string commandIdentity ||
!string.Equals(
commandIdentity,
transaction.IdentityCode,
StringComparison.Ordinal))
{
throw new ArgumentException(
"An operator-catalog command identity is invalid.",
nameof(transaction));
}
}
}
private static OperatorCatalogMutationException UnknownOutcome(
OperatorCatalogMutationKind kind,
Exception exception) =>
new(
$"The {kind} transaction outcome is unknown; do not retry automatically.",
outcomeUnknown: true,
exception);
private sealed class AffectedRowsConflictException(
OperatorCatalogDbCommandKind commandKind,
int affectedRows)
: Exception("An operator-catalog affected-row precondition failed.")
{
internal OperatorCatalogDbCommandKind CommandKind { get; } = commandKind;
internal int AffectedRows { get; } = affectedRows;
}
}

View File

@@ -0,0 +1,345 @@
#nullable enable
using System.Data;
using System.Data.Common;
using System.Text.RegularExpressions;
using MMoneyCoderSharp.Data;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Executes the closed GraphE INPUT_* write contract on one Oracle connection
/// and one transaction. Each operation takes the profile's exclusive table lock,
/// executes its bound command once, validates affected rows before commit, and
/// deliberately contains no retry loop.
/// </summary>
public sealed partial class OracleManualFinancialMutationExecutor
: IManualFinancialMutationExecutor
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
private readonly TimeSpan _operationTimeout;
private readonly int _commandTimeoutSeconds;
private readonly Action<DbCommand> _configureCommand;
public OracleManualFinancialMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector? errorDetector = null)
: this(
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureOracleCommand)
{
}
internal OracleManualFinancialMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DbCommand> configureCommand)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
ArgumentNullException.ThrowIfNull(resilienceOptions);
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ?? throw new ArgumentNullException(nameof(configureCommand));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
_operationTimeout = TimeSpan.FromSeconds(resilienceOptions.OperationTimeoutSeconds);
_commandTimeoutSeconds = connectionFactory.GetCommandTimeoutSeconds(DataSourceKind.Oracle);
if (_commandTimeoutSeconds is < 1 or > 600)
{
throw new DatabaseConfigurationException(
"The Oracle manual-financial command timeout must be between 1 and 600 seconds.");
}
}
public async Task<ManualFinancialDbTransactionResult> ExecuteTransactionAsync(
DataSourceKind source,
ManualFinancialDbTransaction transaction,
CancellationToken cancellationToken = default)
{
if (source != DataSourceKind.Oracle)
{
throw new ArgumentOutOfRangeException(
nameof(source),
source,
"Manual-financial GraphE records are stored in Oracle.");
}
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);
var operationToken = timeoutSource.Token;
DbConnection? connection = null;
DbTransaction? databaseTransaction = null;
var commitStarted = false;
try
{
connection = _connectionFactory.Create(DataSourceKind.Oracle);
await connection.OpenAsync(operationToken).ConfigureAwait(false);
databaseTransaction = await connection.BeginTransactionAsync(
IsolationLevel.ReadCommitted,
operationToken)
.ConfigureAwait(false);
await ExecuteLockAsync(
connection,
databaseTransaction,
transaction.ExclusiveTableLockSql,
operationToken).ConfigureAwait(false);
var affectedRows = new List<int>(transaction.Commands.Count);
foreach (var commandSpec in transaction.Commands)
{
operationToken.ThrowIfCancellationRequested();
var count = await ExecuteCommandAsync(
connection,
databaseTransaction,
commandSpec,
operationToken).ConfigureAwait(false);
if (!Matches(commandSpec.AffectedRowsRule, count))
{
throw Rejected(transaction.Kind, count);
}
affectedRows.Add(count);
}
commitStarted = true;
await databaseTransaction.CommitAsync(operationToken).ConfigureAwait(false);
return new ManualFinancialDbTransactionResult(
transaction.OperationId,
Array.AsReadOnly(affectedRows.ToArray()),
DateTimeOffset.UtcNow);
}
catch (Exception exception)
{
if (commitStarted)
{
// The client cannot prove whether Oracle committed. Issuing a
// rollback or repeating the command would make the situation worse.
throw UnknownOutcome(transaction.Kind, exception);
}
var isCancellationOrTimeout = exception is OperationCanceledException ||
_errorDetector.IsTimeout(DataSourceKind.Oracle, exception);
var rollbackFailed = false;
if (databaseTransaction is not null)
{
rollbackFailed = !await TryRollbackAsync(databaseTransaction)
.ConfigureAwait(false);
}
if (databaseTransaction is not null && (isCancellationOrTimeout || rollbackFailed))
{
throw UnknownOutcome(transaction.Kind, exception);
}
if (exception is ManualFinancialMutationRejectedException rejected)
{
throw rejected;
}
if (exception is ManualFinancialMutationException mutationException)
{
throw mutationException;
}
throw new ManualFinancialMutationException(
$"The {transaction.Kind} Oracle transaction did not commit.",
outcomeUnknown: false,
exception);
}
finally
{
if (databaseTransaction is not null)
{
await databaseTransaction.DisposeAsync().ConfigureAwait(false);
}
if (connection is not null)
{
await connection.DisposeAsync().ConfigureAwait(false);
}
}
}
internal static void ConfigureOracleCommand(DbCommand command)
{
ArgumentNullException.ThrowIfNull(command);
if (command is not OracleCommand oracleCommand)
{
throw new DatabaseConfigurationException(
"The manual-financial mutation connection did not create an Oracle command.");
}
oracleCommand.BindByName = true;
}
private async Task ExecuteLockAsync(
DbConnection connection,
DbTransaction transaction,
string sql,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
Configure(command, transaction, sql);
// Execute exactly once. LOCK TABLE has no operator value and no retry.
_ = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<int> ExecuteCommandAsync(
DbConnection connection,
DbTransaction transaction,
ManualFinancialDbCommand commandSpec,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
Configure(command, transaction, commandSpec.Sql);
foreach (var parameterSpec in commandSpec.Parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = parameterSpec.Name;
parameter.Direction = ParameterDirection.Input;
parameter.DbType = parameterSpec.DbType;
parameter.Value = parameterSpec.Value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
// Execute exactly once. There is deliberately no transient retry.
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private void Configure(DbCommand command, DbTransaction transaction, string sql)
{
command.CommandText = sql;
command.CommandType = CommandType.Text;
command.CommandTimeout = _commandTimeoutSeconds;
command.Transaction = transaction;
_configureCommand(command);
}
private async Task<bool> TryRollbackAsync(DbTransaction transaction)
{
try
{
using var rollbackTimeout = new CancellationTokenSource(
TimeSpan.FromSeconds(_commandTimeoutSeconds));
await transaction.RollbackAsync(rollbackTimeout.Token).ConfigureAwait(false);
return true;
}
catch
{
return false;
}
}
private static void ValidateTransaction(ManualFinancialDbTransaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction);
var expected = transaction.Screen switch
{
ManualFinancialScreenKind.RevenueComposition =>
(Table: "INPUT_PIE", Lock: "LOCK TABLE INPUT_PIE IN EXCLUSIVE MODE"),
ManualFinancialScreenKind.GrowthMetrics =>
(Table: "INPUT_GROW", Lock: "LOCK TABLE INPUT_GROW IN EXCLUSIVE MODE"),
ManualFinancialScreenKind.Sales =>
(Table: "INPUT_SELL", Lock: "LOCK TABLE INPUT_SELL IN EXCLUSIVE MODE"),
ManualFinancialScreenKind.OperatingProfit =>
(Table: "INPUT_PROFIT", Lock: "LOCK TABLE INPUT_PROFIT IN EXCLUSIVE MODE"),
_ => throw InvalidTransaction()
};
if (transaction.OperationId == Guid.Empty ||
!transaction.RequiresExclusiveTableLock ||
!string.Equals(transaction.TableName, expected.Table, StringComparison.Ordinal) ||
!string.Equals(transaction.ExclusiveTableLockSql, expected.Lock, StringComparison.Ordinal) ||
transaction.Commands.Count != 1 || transaction.Commands[0] is null)
{
throw InvalidTransaction();
}
var command = transaction.Commands[0];
var validShape = transaction.Kind switch
{
ManualFinancialMutationKind.Create =>
command.Kind == ManualFinancialDbCommandKind.Insert &&
command.AffectedRowsRule == ManualFinancialAffectedRowsRule.ExactlyOne,
ManualFinancialMutationKind.Update =>
command.Kind == ManualFinancialDbCommandKind.Update &&
command.AffectedRowsRule == ManualFinancialAffectedRowsRule.ExactlyOne,
ManualFinancialMutationKind.DeleteOne =>
command.Kind == ManualFinancialDbCommandKind.DeleteOne &&
command.AffectedRowsRule == ManualFinancialAffectedRowsRule.ExactlyOne,
ManualFinancialMutationKind.DeleteAll =>
command.Kind == ManualFinancialDbCommandKind.DeleteAll &&
command.AffectedRowsRule == ManualFinancialAffectedRowsRule.AnyNonNegative,
_ => false
};
if (!validShape || string.IsNullOrWhiteSpace(command.Sql) || command.Sql.Contains(';'))
{
throw InvalidTransaction();
}
var placeholders = PlaceholderPattern()
.Matches(command.Sql)
.Select(static match => match.Groups[1].Value)
.ToArray();
var parameters = command.Parameters.ToArray();
if (parameters.Any(static parameter =>
parameter is null ||
parameter.DbType != DbType.String ||
parameter.Value is not string) ||
parameters.Select(static parameter => parameter.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count() != parameters.Length ||
!placeholders.SequenceEqual(
parameters.Select(static parameter => parameter.Name),
StringComparer.OrdinalIgnoreCase))
{
throw InvalidTransaction();
}
}
private static bool Matches(ManualFinancialAffectedRowsRule rule, int count) =>
rule switch
{
ManualFinancialAffectedRowsRule.ExactlyOne => count == 1,
ManualFinancialAffectedRowsRule.AnyNonNegative => count >= 0,
_ => false
};
private static ManualFinancialMutationRejectedException Rejected(
ManualFinancialMutationKind kind,
int affectedRows)
{
var reason = affectedRows is < 0 or > 1
? ManualFinancialMutationRejectionReason.AmbiguousIdentity
: kind == ManualFinancialMutationKind.Create
? ManualFinancialMutationRejectionReason.DuplicateIdentity
: ManualFinancialMutationRejectionReason.NotFoundOrChanged;
return new ManualFinancialMutationRejectedException(
reason,
$"The {kind} precondition failed and the Oracle transaction was rolled back.");
}
private static ManualFinancialMutationException UnknownOutcome(
ManualFinancialMutationKind kind,
Exception exception) =>
new(
$"The {kind} Oracle transaction outcome is unknown; do not retry automatically.",
outcomeUnknown: true,
exception);
private static ArgumentException InvalidTransaction() =>
new("The manual-financial transaction is invalid.", "transaction");
[GeneratedRegex(@":([A-Za-z_][A-Za-z0-9_]*)", RegexOptions.CultureInvariant)]
private static partial Regex PlaceholderPattern();
}

View File

@@ -0,0 +1,266 @@
#nullable enable
using System.Data;
using System.Data.Common;
using MMoneyCoderSharp.Data;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Executes the closed DC_LIST/PLAY_LIST mutation contract on one Oracle
/// connection and one transaction. Unlike the read executor, this class has no
/// retry loop: repeating a write after a timeout or ambiguous commit could
/// duplicate or destroy an operator playlist.
/// </summary>
public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutationExecutor
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
private readonly TimeSpan _operationTimeout;
private readonly int _commandTimeoutSeconds;
private readonly Action<DbCommand> _configureCommand;
public OracleNamedPlaylistMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector? errorDetector = null)
: this(
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureOracleCommand)
{
}
internal OracleNamedPlaylistMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DbCommand> configureCommand)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
ArgumentNullException.ThrowIfNull(resilienceOptions);
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ??
throw new ArgumentNullException(nameof(configureCommand));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
_operationTimeout = TimeSpan.FromSeconds(resilienceOptions.OperationTimeoutSeconds);
_commandTimeoutSeconds = connectionFactory.GetCommandTimeoutSeconds(DataSourceKind.Oracle);
if (_commandTimeoutSeconds is < 1 or > 600)
{
throw new DatabaseConfigurationException(
"The Oracle named-playlist command timeout must be between 1 and 600 seconds.");
}
}
public async Task<NamedPlaylistDbTransactionResult> ExecuteTransactionAsync(
DataSourceKind source,
NamedPlaylistDbTransaction transaction,
CancellationToken cancellationToken = default)
{
if (source != DataSourceKind.Oracle)
{
throw new ArgumentOutOfRangeException(
nameof(source),
source,
"Named playlists are stored in Oracle.");
}
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);
var operationToken = timeoutSource.Token;
DbConnection? connection = null;
DbTransaction? databaseTransaction = null;
var commitStarted = false;
try
{
connection = _connectionFactory.Create(DataSourceKind.Oracle);
await connection.OpenAsync(operationToken).ConfigureAwait(false);
databaseTransaction = await connection.BeginTransactionAsync(
IsolationLevel.ReadCommitted,
operationToken)
.ConfigureAwait(false);
var affectedRows = new List<int>(transaction.Commands.Count);
foreach (var commandSpec in transaction.Commands)
{
operationToken.ThrowIfCancellationRequested();
await using var command = connection.CreateCommand();
command.CommandText = commandSpec.Sql;
command.CommandType = CommandType.Text;
command.CommandTimeout = _commandTimeoutSeconds;
command.Transaction = databaseTransaction;
_configureCommand(command);
foreach (var parameterSpec in commandSpec.Parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = parameterSpec.Name;
parameter.Direction = ParameterDirection.Input;
parameter.DbType = parameterSpec.DbType;
parameter.Value = parameterSpec.Value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
// Execute exactly once. There is deliberately no transient retry.
var count = await command.ExecuteNonQueryAsync(operationToken)
.ConfigureAwait(false);
affectedRows.Add(count);
}
commitStarted = true;
await databaseTransaction.CommitAsync(operationToken).ConfigureAwait(false);
return new NamedPlaylistDbTransactionResult(
transaction.OperationId,
Array.AsReadOnly(affectedRows.ToArray()),
DateTimeOffset.UtcNow);
}
catch (Exception exception)
{
if (commitStarted)
{
// Commit may have reached Oracle even when the client observed a
// timeout/cancellation/disconnect. A rollback here cannot prove
// whether commit succeeded, so do not issue another DB command.
throw UnknownOutcome(transaction.Kind, exception);
}
var isCancellationOrTimeout = exception is OperationCanceledException ||
_errorDetector.IsTimeout(DataSourceKind.Oracle, exception);
var rollbackFailed = false;
if (databaseTransaction is not null)
{
rollbackFailed = !await TryRollbackAsync(databaseTransaction)
.ConfigureAwait(false);
}
var outcomeUnknown = databaseTransaction is not null &&
(isCancellationOrTimeout || rollbackFailed);
if (outcomeUnknown)
{
throw UnknownOutcome(transaction.Kind, exception);
}
throw new NamedPlaylistMutationException(
$"The {transaction.Kind} Oracle transaction did not commit.",
outcomeUnknown: false,
exception);
}
finally
{
if (databaseTransaction is not null)
{
await databaseTransaction.DisposeAsync().ConfigureAwait(false);
}
if (connection is not null)
{
await connection.DisposeAsync().ConfigureAwait(false);
}
}
}
internal static void ConfigureOracleCommand(DbCommand command)
{
ArgumentNullException.ThrowIfNull(command);
if (command is not OracleCommand oracleCommand)
{
throw new DatabaseConfigurationException(
"The named-playlist mutation connection did not create an Oracle command.");
}
oracleCommand.BindByName = true;
}
private async Task<bool> TryRollbackAsync(DbTransaction transaction)
{
try
{
using var rollbackTimeout = new CancellationTokenSource(
TimeSpan.FromSeconds(_commandTimeoutSeconds));
await transaction.RollbackAsync(rollbackTimeout.Token).ConfigureAwait(false);
return true;
}
catch
{
return false;
}
}
private static void ValidateTransaction(NamedPlaylistDbTransaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction);
if (transaction.OperationId == Guid.Empty ||
transaction.ProgramCode.Length != 8 ||
transaction.ProgramCode.Any(static character => !char.IsAsciiDigit(character)) ||
transaction.Commands.Count == 0 ||
transaction.Commands.Any(static command => command is null))
{
throw new ArgumentException(
"The named-playlist transaction is invalid.",
nameof(transaction));
}
var validShape = transaction.Kind switch
{
NamedPlaylistMutationKind.CreateDefinition =>
transaction.Commands.Count == 1 &&
transaction.Commands[0].Kind == NamedPlaylistDbCommandKind.InsertDefinition,
NamedPlaylistMutationKind.ReplaceItems =>
transaction.Commands[0].Kind == NamedPlaylistDbCommandKind.DeleteItems &&
transaction.Commands.Skip(1).All(static command =>
command.Kind == NamedPlaylistDbCommandKind.InsertItem),
NamedPlaylistMutationKind.DeletePlaylist =>
transaction.Commands.Count == 2 &&
transaction.Commands[0].Kind == NamedPlaylistDbCommandKind.DeleteItems &&
transaction.Commands[1].Kind == NamedPlaylistDbCommandKind.DeleteDefinition,
_ => false
};
if (!validShape)
{
throw new ArgumentException(
"The named-playlist transaction command order is invalid.",
nameof(transaction));
}
foreach (var command in transaction.Commands)
{
if (string.IsNullOrWhiteSpace(command.Sql) || command.Sql.Contains(';'))
{
throw new ArgumentException(
"The named-playlist transaction SQL is invalid.",
nameof(transaction));
}
var programCodeParameters = command.Parameters.Where(static parameter =>
string.Equals(parameter.Name, "program_code", StringComparison.OrdinalIgnoreCase));
var programCodeParameter = programCodeParameters.SingleOrDefault();
if (programCodeParameter?.Value is not string commandProgramCode ||
!string.Equals(
commandProgramCode,
transaction.ProgramCode,
StringComparison.Ordinal))
{
throw new ArgumentException(
"The named-playlist transaction program identity is invalid.",
nameof(transaction));
}
}
}
private static NamedPlaylistMutationException UnknownOutcome(
NamedPlaylistMutationKind kind,
Exception exception) =>
new(
$"The {kind} Oracle transaction outcome is unknown; do not retry automatically.",
outcomeUnknown: true,
exception);
}

View File

@@ -30,11 +30,11 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
public const int MaximumFileSizeBytes = 32 * 1024;
private const int ExpectedRowCount = 5;
private const int ExpectedColumnCount = 4;
private const int MaximumCellLength = 256;
internal const int ExpectedRowCount = 5;
internal const int ExpectedColumnCount = 4;
internal const int MaximumCellLength = 256;
private static readonly Encoding Cp949Encoding = CreateCp949Encoding();
internal static readonly Encoding Cp949Encoding = CreateCp949Encoding();
private readonly string _trustedDirectory;
private readonly StringComparison _pathComparison = OperatingSystem.IsWindows()
@@ -68,13 +68,7 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var fileName = audience switch
{
S5025ManualAudience.Individual => "개인.dat",
S5025ManualAudience.Foreign => "외국인.dat",
S5025ManualAudience.Institution => "기관.dat",
_ => throw InvalidData()
};
var fileName = GetFileName(audience);
try
{
@@ -91,6 +85,11 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
bufferSize: 4_096,
FileOptions.Asynchronous | FileOptions.SequentialScan))
{
TrustedDirectoryLease.EnsureRegularFileHandle(
stream.SafeFileHandle,
path,
MaximumFileSizeBytes,
allowEmpty: false);
if (stream.Length <= 0 || stream.Length > MaximumFileSizeBytes)
{
throw InvalidData();
@@ -147,7 +146,17 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
}
}
private string GetContainedFilePath(string fileName)
internal string TrustedDirectory => _trustedDirectory;
internal static string GetFileName(S5025ManualAudience audience) => audience switch
{
S5025ManualAudience.Individual => "개인.dat",
S5025ManualAudience.Foreign => "외국인.dat",
S5025ManualAudience.Institution => "기관.dat",
_ => throw InvalidData()
};
internal string GetContainedFilePath(string fileName)
{
var candidate = Path.GetFullPath(Path.Combine(_trustedDirectory, fileName));
var prefix = Path.EndsInDirectorySeparator(_trustedDirectory)
@@ -195,19 +204,14 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
}
}
// The approved legacy files contain five data rows followed by one empty
// terminal record. s5025 intentionally rendered `row - 1`, thereby
// ignoring exactly that record. Preserve that contract without accepting
// internal blank rows or an arbitrary number of trailing records.
if (lines.Count == ExpectedRowCount + 1 && lines[^1].Length == 0)
{
lines.RemoveAt(lines.Count - 1);
}
if (lines.Count != ExpectedRowCount)
// FSell always wrote five rows plus exactly one empty terminal record.
// Original s5025 rendered `row - 1`; accepting a five-row file without
// that record would therefore render one more row than the original.
if (lines.Count != ExpectedRowCount + 1 || lines[^1].Length != 0)
{
throw InvalidData();
}
lines.RemoveAt(lines.Count - 1);
var rows = new S5025TrustedManualRow[ExpectedRowCount];
for (var rowIndex = 0; rowIndex < lines.Count; rowIndex++)
@@ -261,7 +265,7 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
}
}
private static void EnsureDirectoryIsTrusted(string directory)
internal static void EnsureDirectoryIsTrusted(string directory)
{
var current = new DirectoryInfo(directory);
while (current is not null)
@@ -277,7 +281,7 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
}
}
private static void EnsureRegularNonReparseFile(string path)
internal static void EnsureRegularNonReparseFile(string path)
{
var attributes = File.GetAttributes(path);
if ((attributes & FileAttributes.Directory) != 0
@@ -313,5 +317,191 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
}
}
private static S5025TrustedManualDataException InvalidData() => new();
internal static S5025TrustedManualDataException InvalidData() => new();
}
/// <summary>
/// Closed read/write boundary used by the migrated FSell operator screen. The
/// browser selects only one of the three legacy audiences; it can never supply
/// a directory or file name. Writes are validated, CP949 encoded, and replaced
/// atomically inside the composition-root-owned trusted directory.
/// </summary>
public sealed class S5025TrustedManualFileStore : IS5025TrustedManualDataSource, IDisposable
{
private readonly TrustedDirectoryLease _directoryLease;
private readonly S5025TrustedManualFileDataSource _source;
public S5025TrustedManualFileStore(string trustedDirectory)
{
_directoryLease = TrustedManualDirectory.AcquirePrivateWritableLease(trustedDirectory);
_source = new S5025TrustedManualFileDataSource(trustedDirectory);
}
public static S5025TrustedManualFileStore CreateFromEnvironment(
Func<string, string?>? readEnvironmentVariable = null)
{
var read = readEnvironmentVariable ?? Environment.GetEnvironmentVariable;
return new S5025TrustedManualFileStore(
read(S5025TrustedManualFileDataSource.DirectoryEnvironmentVariable) ?? string.Empty);
}
public Task<IReadOnlyList<S5025TrustedManualRow>> ReadAsync(
S5025ManualAudience audience,
CancellationToken cancellationToken = default) =>
_source.ReadAsync(audience, cancellationToken);
public async Task WriteAsync(
S5025ManualAudience audience,
IReadOnlyList<S5025TrustedManualRow> rows,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var fileName = S5025TrustedManualFileDataSource.GetFileName(audience);
ValidateRows(rows);
string text;
byte[] bytes;
try
{
// FSell wrote five records and one empty terminal record. Preserve
// that exact shape because the scene reader deliberately validates it.
text = string.Join("\r\n", rows.Select(row => string.Join(
'^',
row.LeftName,
row.LeftAmount,
row.RightName,
row.RightAmount))) + "\r\n\r\n";
bytes = S5025TrustedManualFileDataSource.Cp949Encoding.GetBytes(text);
}
catch (EncoderFallbackException)
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
if (bytes.Length <= 0 ||
bytes.Length > S5025TrustedManualFileDataSource.MaximumFileSizeBytes)
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
var directory = _source.TrustedDirectory;
var destination = _source.GetContainedFilePath(fileName);
var temporaryName = $".{fileName}.{Guid.NewGuid():N}.tmp";
var temporary = _source.GetContainedFilePath(temporaryName);
try
{
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
if (File.Exists(destination))
{
S5025TrustedManualFileDataSource.EnsureRegularNonReparseFile(destination);
}
await using (var stream = new FileStream(
temporary,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 4_096,
FileOptions.Asynchronous | FileOptions.WriteThrough))
{
TrustedDirectoryLease.EnsureRegularFileHandle(
stream.SafeFileHandle,
temporary,
S5025TrustedManualFileDataSource.MaximumFileSizeBytes,
allowEmpty: true);
await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
stream.Flush(flushToDisk: true);
}
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
S5025TrustedManualFileDataSource.EnsureRegularNonReparseFile(temporary);
if (File.Exists(destination))
{
S5025TrustedManualFileDataSource.EnsureRegularNonReparseFile(destination);
}
File.Move(temporary, destination, overwrite: true);
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
S5025TrustedManualFileDataSource.EnsureRegularNonReparseFile(destination);
// Read through the production parser before reporting success. This
// makes a malformed or partially persisted file fail closed.
_ = await _source.ReadAsync(audience, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (S5025TrustedManualDataException)
{
throw;
}
catch (Exception exception) when (exception is
IOException or
UnauthorizedAccessException or
SecurityException or
ArgumentException or
NotSupportedException)
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
finally
{
try
{
if (File.Exists(temporary))
{
var attributes = File.GetAttributes(temporary);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == 0)
{
File.Delete(temporary);
}
}
}
catch (Exception exception) when (exception is
IOException or
UnauthorizedAccessException or
SecurityException)
{
// A failed cleanup does not alter the success/failure result. A
// hidden temporary file is never considered by the scene source.
}
}
}
public void Dispose() => _directoryLease.Dispose();
private static void ValidateRows(IReadOnlyList<S5025TrustedManualRow>? rows)
{
if (rows is null || rows.Count != S5025TrustedManualFileDataSource.ExpectedRowCount)
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
foreach (var row in rows)
{
if (row is null)
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
var values = new[]
{
row.LeftName,
row.LeftAmount,
row.RightName,
row.RightAmount
};
if (values.Any(value =>
value is null ||
value.Length > S5025TrustedManualFileDataSource.MaximumCellLength ||
value.Contains('^') ||
value.Any(char.IsControl)))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
}
}
}

View File

@@ -0,0 +1,367 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Creates the app-owned operator-data directory without following an existing
/// reparse ancestor, replaces inherited ACLs with a closed per-user ACL, and pins
/// every directory component against rename/delete while a writable store is alive.
/// </summary>
public static class TrustedManualDirectory
{
public static string PreparePrivateWritableDirectory(string directory)
{
var fullPath = NormalizeAbsolutePath(directory);
ValidateExistingAncestorsBeforeCreate(fullPath);
Directory.CreateDirectory(fullPath);
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(fullPath);
if (OperatingSystem.IsWindows())
{
ApplyPrivateWindowsAcl(fullPath);
ValidatePrivateWindowsAcl(fullPath);
}
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(fullPath);
return fullPath;
}
internal static TrustedDirectoryLease AcquirePrivateWritableLease(string directory)
{
var fullPath = NormalizeAbsolutePath(directory);
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(fullPath);
if (OperatingSystem.IsWindows())
{
ValidatePrivateWindowsAcl(fullPath);
}
return TrustedDirectoryLease.Acquire(fullPath);
}
internal static string NormalizeAbsolutePath(string directory)
{
if (string.IsNullOrWhiteSpace(directory) ||
directory.IndexOf('\0') >= 0 ||
!Path.IsPathFullyQualified(directory))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
return Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
}
private static void ValidateExistingAncestorsBeforeCreate(string fullPath)
{
var current = new DirectoryInfo(fullPath);
while (!current.Exists)
{
if (File.Exists(current.FullName))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
current = current.Parent ??
throw S5025TrustedManualFileDataSource.InvalidData();
}
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(current.FullName);
}
[SupportedOSPlatform("windows")]
private static void ApplyPrivateWindowsAcl(string fullPath)
{
var currentUser = GetCurrentUserSid();
var security = new DirectorySecurity();
security.SetOwner(currentUser);
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
const InheritanceFlags inheritance =
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
AddFullControl(security, currentUser, inheritance);
AddFullControl(security, new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), inheritance);
AddFullControl(
security,
new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
inheritance);
new DirectoryInfo(fullPath).SetAccessControl(security);
}
[SupportedOSPlatform("windows")]
private static void ValidatePrivateWindowsAcl(string fullPath)
{
var currentUser = GetCurrentUserSid();
var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
var administrators = new SecurityIdentifier(
WellKnownSidType.BuiltinAdministratorsSid,
null);
var allowed = new HashSet<string>(StringComparer.Ordinal)
{
currentUser.Value,
system.Value,
administrators.Value
};
var security = new DirectoryInfo(fullPath).GetAccessControl(
AccessControlSections.Access | AccessControlSections.Owner);
var owner = security.GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier;
if (!security.AreAccessRulesProtected ||
owner is null ||
!allowed.Contains(owner.Value))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
var currentUserHasFullControl = false;
foreach (FileSystemAccessRule rule in security.GetAccessRules(
includeExplicit: true,
includeInherited: true,
targetType: typeof(SecurityIdentifier)))
{
var sid = (SecurityIdentifier)rule.IdentityReference;
if (rule.AccessControlType != AccessControlType.Allow ||
!allowed.Contains(sid.Value))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
if (sid.Equals(currentUser) &&
(rule.FileSystemRights & FileSystemRights.FullControl) ==
FileSystemRights.FullControl)
{
currentUserHasFullControl = true;
}
}
if (!currentUserHasFullControl)
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
}
[SupportedOSPlatform("windows")]
private static SecurityIdentifier GetCurrentUserSid() =>
WindowsIdentity.GetCurrent().User ??
throw S5025TrustedManualFileDataSource.InvalidData();
[SupportedOSPlatform("windows")]
private static void AddFullControl(
DirectorySecurity security,
SecurityIdentifier identity,
InheritanceFlags inheritance) =>
security.AddAccessRule(new FileSystemAccessRule(
identity,
FileSystemRights.FullControl,
inheritance,
PropagationFlags.None,
AccessControlType.Allow));
}
internal sealed class TrustedDirectoryLease : IDisposable
{
private const uint GenericRead = 0x80000000;
private const uint OpenExisting = 3;
private const uint FileFlagBackupSemantics = 0x02000000;
private const uint FileFlagOpenReparsePoint = 0x00200000;
private const uint FileNameNormalized = 0;
private readonly IReadOnlyList<SafeFileHandle> _directoryHandles;
private TrustedDirectoryLease(IReadOnlyList<SafeFileHandle> directoryHandles)
{
_directoryHandles = directoryHandles;
}
public static TrustedDirectoryLease Acquire(string directory)
{
if (!OperatingSystem.IsWindows())
{
return new TrustedDirectoryLease(Array.Empty<SafeFileHandle>());
}
var ancestors = new List<string>();
for (var current = new DirectoryInfo(directory); current is not null; current = current.Parent)
{
ancestors.Add(current.FullName);
}
ancestors.Reverse();
var handles = new List<SafeFileHandle>(ancestors.Count);
try
{
foreach (var ancestor in ancestors)
{
var handle = CreateFileW(
ancestor,
GenericRead,
FileShare.Read | FileShare.Write,
IntPtr.Zero,
OpenExisting,
FileFlagBackupSemantics | FileFlagOpenReparsePoint,
IntPtr.Zero);
if (handle.IsInvalid)
{
handle.Dispose();
throw new Win32Exception(Marshal.GetLastWin32Error());
}
EnsureDirectoryHandleIsDirect(handle, ancestor);
handles.Add(handle);
}
return new TrustedDirectoryLease(handles.AsReadOnly());
}
catch
{
foreach (var handle in handles)
{
handle.Dispose();
}
throw S5025TrustedManualFileDataSource.InvalidData();
}
}
public static void EnsureRegularFileHandle(
SafeFileHandle handle,
string expectedPath,
long maximumLength,
bool allowEmpty)
{
if (!OperatingSystem.IsWindows())
{
return;
}
if (!GetFileInformationByHandle(handle, out var information) ||
(information.FileAttributes &
(FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
information.NumberOfLinks != 1)
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
var length = ((long)information.FileSizeHigh << 32) | information.FileSizeLow;
if (length > maximumLength || (!allowEmpty && length <= 0))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
var resolved = GetFinalPath(handle);
var expected = Path.GetFullPath(expectedPath);
if (!string.Equals(resolved, expected, StringComparison.OrdinalIgnoreCase))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
}
public void Dispose()
{
foreach (var handle in _directoryHandles.Reverse())
{
handle.Dispose();
}
}
private static void EnsureDirectoryHandleIsDirect(SafeFileHandle handle, string expectedPath)
{
if (!GetFileInformationByHandle(handle, out var information) ||
(information.FileAttributes & FileAttributes.Directory) == 0 ||
(information.FileAttributes & FileAttributes.ReparsePoint) != 0 ||
!string.Equals(
GetFinalPath(handle),
Path.GetFullPath(expectedPath),
StringComparison.OrdinalIgnoreCase))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
}
private static string GetFinalPath(SafeFileHandle handle)
{
var capacity = 512;
while (capacity <= 32_768)
{
var buffer = new StringBuilder(capacity);
var length = GetFinalPathNameByHandleW(
handle,
buffer,
(uint)buffer.Capacity,
FileNameNormalized);
if (length == 0)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
if (length < buffer.Capacity)
{
var path = buffer.ToString();
if (path.StartsWith("\\\\?\\UNC\\", StringComparison.OrdinalIgnoreCase))
{
path = "\\\\" + path[8..];
}
else if (path.StartsWith("\\\\?\\", StringComparison.Ordinal))
{
path = path[4..];
}
return Path.GetFullPath(path);
}
capacity = checked((int)length + 1);
}
throw S5025TrustedManualFileDataSource.InvalidData();
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string fileName,
uint desiredAccess,
FileShare shareMode,
IntPtr securityAttributes,
uint creationDisposition,
uint flagsAndAttributes,
IntPtr templateFile);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetFileInformationByHandle(
SafeFileHandle file,
out ByHandleFileInformation information);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle file,
StringBuilder path,
uint pathLength,
uint flags);
[StructLayout(LayoutKind.Sequential)]
private struct ByHandleFileInformation
{
public FileAttributes FileAttributes;
public FileTime CreationTime;
public FileTime LastAccessTime;
public FileTime LastWriteTime;
public uint VolumeSerialNumber;
public uint FileSizeHigh;
public uint FileSizeLow;
public uint NumberOfLinks;
public uint FileIndexHigh;
public uint FileIndexLow;
}
[StructLayout(LayoutKind.Sequential)]
private struct FileTime
{
public uint Low;
public uint High;
}
}

View File

@@ -0,0 +1,377 @@
using System.Security;
using System.Security.Cryptography;
using System.Text;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
public sealed record ViTrustedManualItem(string Code, string Name);
public sealed record ViTrustedManualSnapshot(
IReadOnlyList<ViTrustedManualItem> Items,
string RowVersion);
public sealed record ViTrustedManualWriteResult(
ViTrustedManualSnapshot Snapshot,
bool Persisted);
public sealed class ViTrustedManualDataException : Exception
{
internal ViTrustedManualDataException()
: base("The trusted VI manual-data store is unavailable or invalid.")
{
}
}
/// <summary>
/// Closed store for the original VI발동.dat operator list. The path remains a
/// composition-root decision; WebView callers can read or replace only the
/// validated code/name rows and cannot choose a file.
/// </summary>
public sealed class ViTrustedManualFileStore : ITrustedViStockNameSnapshotSource, IDisposable
{
public const int MaximumItemCount = 100;
public const int MaximumFileSizeBytes = 64 * 1024;
private const string FileName = "VI발동.dat";
private const int MaximumCodeLength = 64;
private const int MaximumNameLength = 200;
private static readonly Encoding Cp949 = CreateCp949Encoding();
private readonly TrustedDirectoryLease _directoryLease;
private readonly S5025TrustedManualFileDataSource _pathBoundary;
public ViTrustedManualFileStore(string trustedDirectory)
{
_directoryLease = TrustedManualDirectory.AcquirePrivateWritableLease(trustedDirectory);
_pathBoundary = new S5025TrustedManualFileDataSource(trustedDirectory);
}
public async Task<IReadOnlyList<ViTrustedManualItem>> ReadAsync(
CancellationToken cancellationToken = default) =>
(await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false)).Items;
public async Task<ViTrustedManualSnapshot> ReadSnapshotAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var directory = _pathBoundary.TrustedDirectory;
var path = _pathBoundary.GetContainedFilePath(FileName);
try
{
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
if (!File.Exists(path))
{
return CreateSnapshot(Array.Empty<ViTrustedManualItem>());
}
var attributes = File.GetAttributes(path);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw InvalidData();
}
byte[] bytes;
await using (var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4_096,
FileOptions.Asynchronous | FileOptions.SequentialScan))
{
TrustedDirectoryLease.EnsureRegularFileHandle(
stream.SafeFileHandle,
path,
MaximumFileSizeBytes,
allowEmpty: true);
if (stream.Length > MaximumFileSizeBytes)
{
throw InvalidData();
}
bytes = new byte[checked((int)stream.Length)];
var offset = 0;
while (offset < bytes.Length)
{
var read = await stream.ReadAsync(
bytes.AsMemory(offset), cancellationToken).ConfigureAwait(false);
if (read == 0)
{
throw InvalidData();
}
offset += read;
}
}
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
EnsureViFile(path);
if (bytes.Length == 0)
{
return CreateSnapshot(Array.Empty<ViTrustedManualItem>());
}
RejectUnicodeBom(bytes);
var text = Cp949.GetString(bytes);
var rows = new List<ViTrustedManualItem>();
using var reader = new StringReader(text);
while (reader.ReadLine() is { } rawLine)
{
var line = rawLine.Trim();
if (line.Length == 0)
{
continue;
}
var columns = line.Split('^');
if (columns.Length != 9 || columns.Skip(2).Any(value => value.Length != 0))
{
throw InvalidData();
}
var item = new ViTrustedManualItem(columns[0], columns[1]);
ValidateItem(item);
rows.Add(item);
if (rows.Count > MaximumItemCount)
{
throw InvalidData();
}
}
return CreateSnapshot(rows);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (ViTrustedManualDataException)
{
throw;
}
catch (Exception exception) when (exception is
IOException or UnauthorizedAccessException or SecurityException or
ArgumentException or NotSupportedException or DecoderFallbackException)
{
throw InvalidData();
}
}
public async Task<ViTrustedManualWriteResult> WriteAsync(
IReadOnlyList<ViTrustedManualItem> items,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (items is null || items.Count > MaximumItemCount)
{
throw InvalidData();
}
foreach (var item in items)
{
ValidateItem(item);
}
// Original VILIST did not call WriteListFile when the grid was empty.
// Preserve the existing file and return its current version explicitly.
if (items.Count == 0)
{
return new ViTrustedManualWriteResult(
await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false),
Persisted: false);
}
byte[] bytes;
try
{
bytes = SerializeCanonical(items);
}
catch (EncoderFallbackException)
{
throw InvalidData();
}
if (bytes.Length > MaximumFileSizeBytes)
{
throw InvalidData();
}
var directory = _pathBoundary.TrustedDirectory;
var destination = _pathBoundary.GetContainedFilePath(FileName);
var temporary = _pathBoundary.GetContainedFilePath(
$".{FileName}.{Guid.NewGuid():N}.tmp");
try
{
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
if (File.Exists(destination))
{
EnsureViFile(destination);
}
await using (var stream = new FileStream(
temporary,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 4_096,
FileOptions.Asynchronous | FileOptions.WriteThrough))
{
TrustedDirectoryLease.EnsureRegularFileHandle(
stream.SafeFileHandle,
temporary,
MaximumFileSizeBytes,
allowEmpty: true);
await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
stream.Flush(flushToDisk: true);
}
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
var temporaryAttributes = File.GetAttributes(temporary);
if ((temporaryAttributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
new FileInfo(temporary).Length > MaximumFileSizeBytes)
{
throw InvalidData();
}
if (File.Exists(destination))
{
EnsureViFile(destination);
}
File.Move(temporary, destination, overwrite: true);
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
var persisted = await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false);
var expectedVersion = ComputeRowVersion(bytes);
if (!persisted.Items.SequenceEqual(items) ||
!string.Equals(persisted.RowVersion, expectedVersion, StringComparison.Ordinal))
{
throw InvalidData();
}
return new ViTrustedManualWriteResult(persisted, Persisted: true);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (ViTrustedManualDataException)
{
throw;
}
catch (Exception exception) when (exception is
IOException or UnauthorizedAccessException or SecurityException or
ArgumentException or NotSupportedException)
{
throw InvalidData();
}
finally
{
try
{
if (File.Exists(temporary))
{
var attributes = File.GetAttributes(temporary);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == 0)
{
File.Delete(temporary);
}
}
}
catch (Exception exception) when (exception is
IOException or UnauthorizedAccessException or SecurityException)
{
// A leftover hidden temporary file is never consumed by the store.
}
}
}
public async Task<TrustedViStockNameSnapshot> ReadStockNameSnapshotAsync(
CancellationToken cancellationToken = default)
{
var snapshot = await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false);
return new TrustedViStockNameSnapshot(
Array.AsReadOnly(snapshot.Items.Select(item => item.Name).ToArray()),
snapshot.RowVersion);
}
public void Dispose() => _directoryLease.Dispose();
private static void ValidateItem(ViTrustedManualItem? item)
{
if (item is null ||
!IsSafe(item.Code, MaximumCodeLength) ||
!IsSafe(item.Name, MaximumNameLength) ||
item.Name.Contains(','))
{
throw InvalidData();
}
}
private static bool IsSafe(string? value, int maximumLength) =>
value is not null &&
value.Length is > 0 &&
value.Length <= maximumLength &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
!value.Contains('^') &&
!value.Any(char.IsControl);
private static void EnsureViFile(string path)
{
var attributes = File.GetAttributes(path);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
new FileInfo(path).Length > MaximumFileSizeBytes)
{
throw InvalidData();
}
}
private static ViTrustedManualSnapshot CreateSnapshot(
IReadOnlyList<ViTrustedManualItem> items)
{
var immutable = Array.AsReadOnly(items.ToArray());
byte[] canonical;
try
{
canonical = SerializeCanonical(immutable);
}
catch (EncoderFallbackException)
{
throw InvalidData();
}
return new ViTrustedManualSnapshot(immutable, ComputeRowVersion(canonical));
}
private static byte[] SerializeCanonical(IEnumerable<ViTrustedManualItem> items)
{
var text = string.Concat(items.Select(item =>
$"{item.Code}^{item.Name}^^^^^^^\r\n"));
return Cp949.GetBytes(text);
}
private static string ComputeRowVersion(ReadOnlySpan<byte> canonicalBytes) =>
Convert.ToHexString(SHA256.HashData(canonicalBytes)).ToLowerInvariant();
private static Encoding CreateCp949Encoding()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
}
private static void RejectUnicodeBom(ReadOnlySpan<byte> bytes)
{
if (bytes.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) ||
bytes.StartsWith(new byte[] { 0xFF, 0xFE }) ||
bytes.StartsWith(new byte[] { 0xFE, 0xFF }) ||
bytes.StartsWith(new byte[] { 0x00, 0x00, 0xFE, 0xFF }))
{
throw InvalidData();
}
}
private static ViTrustedManualDataException InvalidData() => new();
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Infrastructure.Tests")]