Complete legacy operator UI and playout migration
This commit is contained in:
480
src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs
Normal file
480
src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs
Normal 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();
|
||||
}
|
||||
540
src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs
Normal file
540
src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs
Normal 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();
|
||||
}
|
||||
141
src/MBN_STOCK_WEBVIEW.Core/Data/IndustrySelection.cs
Normal file
141
src/MBN_STOCK_WEBVIEW.Core/Data/IndustrySelection.cs
Normal 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);
|
||||
}
|
||||
1816
src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs
Normal file
1816
src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs
Normal file
File diff suppressed because it is too large
Load Diff
1004
src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs
Normal file
1004
src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
242
src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs
Normal file
242
src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs
Normal 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}.");
|
||||
}
|
||||
281
src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs
Normal file
281
src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs
Normal 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
|
||||
""")
|
||||
];
|
||||
}
|
||||
587
src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs
Normal file
587
src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs
Normal 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.")
|
||||
};
|
||||
}
|
||||
620
src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs
Normal file
620
src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs
Normal 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.")
|
||||
};
|
||||
}
|
||||
324
src/MBN_STOCK_WEBVIEW.Core/Data/TradingHaltSelection.cs
Normal file
324
src/MBN_STOCK_WEBVIEW.Core/Data/TradingHaltSelection.cs
Normal 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)
|
||||
];
|
||||
}
|
||||
228
src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs
Normal file
228
src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs
Normal 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.");
|
||||
}
|
||||
50
src/MBN_STOCK_WEBVIEW.Core/Playout/OperatorMutationGate.cs
Normal file
50
src/MBN_STOCK_WEBVIEW.Core/Playout/OperatorMutationGate.cs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
""";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Infrastructure.Tests")]
|
||||
Reference in New Issue
Block a user