Complete legacy operator UI and playout migration
This commit is contained in:
786
MainWindow.ManualLists.cs
Normal file
786
MainWindow.ManualLists.cs
Normal file
@@ -0,0 +1,786 @@
|
||||
using System.Text.Json;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW;
|
||||
|
||||
public sealed partial class MainWindow
|
||||
{
|
||||
private const string OperatorDataDirectoryEnvironmentVariable =
|
||||
"MBN_STOCK_OPERATOR_DATA_DIRECTORY";
|
||||
private const int MaximumManualValueLength = 256;
|
||||
private const string ManualOperatorCorrelationQuarantineMessage =
|
||||
"An operator-data file write result could not be confirmed. Restart the app and reconcile the trusted files before another write.";
|
||||
|
||||
private readonly SemaphoreSlim _manualOperatorDataGate = new(1, 1);
|
||||
private S5025TrustedManualFileStore? _manualNetSellStore;
|
||||
private ViTrustedManualFileStore? _viManualListStore;
|
||||
private string? _manualOperatorDataInitializationError;
|
||||
private int _manualOperatorWriteInFlight;
|
||||
private int _manualOperatorWriteQuarantined;
|
||||
private int _manualOperatorBrowserGeneration;
|
||||
private string? _manualOperatorWriteQuarantineMessage =
|
||||
ManualOperatorCorrelationQuarantineMessage;
|
||||
|
||||
private void InitializeManualOperatorDataRuntime()
|
||||
{
|
||||
try
|
||||
{
|
||||
var configuredDirectory = Environment.GetEnvironmentVariable(
|
||||
OperatorDataDirectoryEnvironmentVariable);
|
||||
if (!string.IsNullOrWhiteSpace(configuredDirectory))
|
||||
{
|
||||
_manualOperatorDataInitializationError =
|
||||
"외부 수동 데이터 경로는 TOCTOU 안전성을 보장할 수 없어 비활성화되었습니다. 앱 전용 로컬 운영 데이터 폴더를 사용하세요.";
|
||||
return;
|
||||
}
|
||||
|
||||
var directory = TrustedManualDirectory.PreparePrivateWritableDirectory(Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"MBN_STOCK_WEBVIEW",
|
||||
"OperatorData"));
|
||||
_manualNetSellStore = new S5025TrustedManualFileStore(directory);
|
||||
_viManualListStore = new ViTrustedManualFileStore(directory);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_viManualListStore?.Dispose();
|
||||
_manualNetSellStore?.Dispose();
|
||||
_viManualListStore = null;
|
||||
_manualNetSellStore = null;
|
||||
_manualOperatorDataInitializationError =
|
||||
"수동 송출 데이터 저장소를 초기화하지 못했습니다. 로컬 운영 데이터 설정을 확인하세요.";
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleManualOperatorStatusRequest(JsonElement payload)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(payload, "requestId") ||
|
||||
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId))
|
||||
{
|
||||
PostMessage("manual-operator-data-error", new
|
||||
{
|
||||
requestId = SafeManualOperatorRequestId(payload),
|
||||
operation = "status",
|
||||
code = "INVALID_REQUEST",
|
||||
message = "올바르지 않은 수동 데이터 상태 요청입니다.",
|
||||
retryable = false,
|
||||
outcomeUnknown = false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
PostMessage("manual-operator-data-status", new
|
||||
{
|
||||
requestId,
|
||||
available = _manualNetSellStore is not null && _viManualListStore is not null,
|
||||
writeQuarantined = IsManualOperatorWriteQuarantined(),
|
||||
message = IsManualOperatorWriteQuarantined()
|
||||
? GetOperatorMutationQuarantineMessage(
|
||||
_manualOperatorWriteQuarantineMessage ??
|
||||
ManualOperatorCorrelationQuarantineMessage)
|
||||
: _manualOperatorDataInitializationError
|
||||
});
|
||||
}
|
||||
|
||||
private async Task HandleManualNetSellReadAsync(JsonElement payload)
|
||||
{
|
||||
if (!TryParseAudienceRequest(payload, out var requestId, out var audience))
|
||||
{
|
||||
PostManualOperatorError(
|
||||
requestId,
|
||||
"read-net-sell",
|
||||
"INVALID_REQUEST",
|
||||
"올바르지 않은 수동 순매도 조회 요청입니다.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
|
||||
var store = _manualNetSellStore;
|
||||
if (store is null)
|
||||
{
|
||||
PostManualOperatorUnavailable(requestId, "read-net-sell");
|
||||
return;
|
||||
}
|
||||
|
||||
var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration);
|
||||
await _manualOperatorDataGate.WaitAsync(_lifetimeCancellation.Token);
|
||||
try
|
||||
{
|
||||
var rows = await store.ReadAsync(audience, _lifetimeCancellation.Token);
|
||||
if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration))
|
||||
{
|
||||
return;
|
||||
}
|
||||
PostMessage("manual-net-sell-data", new
|
||||
{
|
||||
requestId,
|
||||
audience = ToWireValue(audience),
|
||||
rows = rows.Select(row => new
|
||||
{
|
||||
row.LeftName,
|
||||
row.LeftAmount,
|
||||
row.RightName,
|
||||
row.RightAmount
|
||||
})
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested)
|
||||
{
|
||||
// Window shutdown owns cancellation and no stale browser response is posted.
|
||||
}
|
||||
catch (S5025TrustedManualDataException)
|
||||
{
|
||||
PostManualOperatorError(
|
||||
requestId,
|
||||
"read-net-sell",
|
||||
"DATA_INVALID",
|
||||
"수동 순매도 데이터 파일이 없거나 형식이 올바르지 않습니다.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_manualOperatorDataGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleManualNetSellWriteAsync(JsonElement payload)
|
||||
{
|
||||
if (!TryParseManualNetSellWrite(
|
||||
payload,
|
||||
out var requestId,
|
||||
out var audience,
|
||||
out var rows))
|
||||
{
|
||||
PostManualOperatorError(
|
||||
requestId,
|
||||
"save-net-sell",
|
||||
"INVALID_REQUEST",
|
||||
"수동 순매도 입력은 5행의 검증된 값이어야 합니다.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
|
||||
var store = _manualNetSellStore;
|
||||
if (store is null)
|
||||
{
|
||||
PostManualOperatorUnavailable(requestId, "save-net-sell");
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsManualOperatorWriteQuarantined())
|
||||
{
|
||||
PostManualOperatorQuarantine(requestId, "save-net-sell");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await TryEnterManualMutationGatesAsync(requestId, "save-net-sell"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration);
|
||||
if (!CanStartOperatorMutation(IsManualOperatorWriteQuarantined()))
|
||||
{
|
||||
ExitManualMutationGates();
|
||||
PostManualOperatorError(
|
||||
requestId,
|
||||
"save-net-sell",
|
||||
"PLAYOUT_ACTIVE",
|
||||
"Operator data cannot be changed unless playout is verifiably idle.",
|
||||
retryable: true,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
Interlocked.Exchange(ref _manualOperatorWriteInFlight, 1);
|
||||
try
|
||||
{
|
||||
await store.WriteAsync(audience, rows, _lifetimeCancellation.Token);
|
||||
if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration))
|
||||
{
|
||||
QuarantineManualOperatorWrites(
|
||||
"수동 순매도 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요.");
|
||||
return;
|
||||
}
|
||||
PostManualNetSellWriteResult(requestId, audience, recovered: false);
|
||||
}
|
||||
catch (Exception exception) when (exception is
|
||||
S5025TrustedManualDataException or OperationCanceledException)
|
||||
{
|
||||
if (_lifetimeCancellation.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var persisted = await store.ReadAsync(audience, CancellationToken.None);
|
||||
if (persisted.SequenceEqual(rows))
|
||||
{
|
||||
if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration))
|
||||
{
|
||||
QuarantineManualOperatorWrites(
|
||||
"수동 순매도 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요.");
|
||||
return;
|
||||
}
|
||||
PostManualNetSellWriteResult(requestId, audience, recovered: true);
|
||||
return;
|
||||
}
|
||||
|
||||
QuarantineManualOperatorWrites(
|
||||
"수동 순매도 저장 중 파일 내용이 달라져 결과를 확정할 수 없습니다. 파일을 직접 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요.");
|
||||
PostManualOperatorQuarantine(requestId, "save-net-sell");
|
||||
}
|
||||
catch
|
||||
{
|
||||
QuarantineManualOperatorWrites(
|
||||
"수동 순매도 저장 결과를 확인할 수 없습니다. 파일을 직접 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요.");
|
||||
PostManualOperatorQuarantine(requestId, "save-net-sell");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _manualOperatorWriteInFlight, 0);
|
||||
ExitManualMutationGates();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleViManualListReadAsync(JsonElement payload)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(payload, "requestId") ||
|
||||
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId))
|
||||
{
|
||||
PostManualOperatorError(
|
||||
SafeManualOperatorRequestId(payload),
|
||||
"read-vi",
|
||||
"INVALID_REQUEST",
|
||||
"올바르지 않은 VI 수동 목록 조회 요청입니다.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
|
||||
var store = _viManualListStore;
|
||||
if (store is null)
|
||||
{
|
||||
PostManualOperatorUnavailable(requestId, "read-vi");
|
||||
return;
|
||||
}
|
||||
|
||||
var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration);
|
||||
await _manualOperatorDataGate.WaitAsync(_lifetimeCancellation.Token);
|
||||
try
|
||||
{
|
||||
var snapshot = await store.ReadSnapshotAsync(_lifetimeCancellation.Token);
|
||||
if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration))
|
||||
{
|
||||
return;
|
||||
}
|
||||
PostMessage("vi-manual-list-data", new
|
||||
{
|
||||
requestId,
|
||||
itemCount = snapshot.Items.Count,
|
||||
pageCount = snapshot.Items.Count == 0 ? 0 : (snapshot.Items.Count + 4) / 5,
|
||||
items = snapshot.Items,
|
||||
version = snapshot.RowVersion
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested)
|
||||
{
|
||||
// Window shutdown owns cancellation.
|
||||
}
|
||||
catch (ViTrustedManualDataException)
|
||||
{
|
||||
PostManualOperatorError(
|
||||
requestId,
|
||||
"read-vi",
|
||||
"DATA_INVALID",
|
||||
"VI 수동 목록 파일 형식이 올바르지 않습니다.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_manualOperatorDataGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleViManualListWriteAsync(JsonElement payload)
|
||||
{
|
||||
if (!TryParseViWrite(
|
||||
payload,
|
||||
out var requestId,
|
||||
out var expectedVersion,
|
||||
out var items))
|
||||
{
|
||||
PostManualOperatorError(
|
||||
requestId,
|
||||
"save-vi",
|
||||
"INVALID_REQUEST",
|
||||
$"VI 수동 목록은 최대 {ViTrustedManualFileStore.MaximumItemCount}개의 검증된 종목이어야 합니다.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
|
||||
var store = _viManualListStore;
|
||||
if (store is null)
|
||||
{
|
||||
PostManualOperatorUnavailable(requestId, "save-vi");
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsManualOperatorWriteQuarantined())
|
||||
{
|
||||
PostManualOperatorQuarantine(requestId, "save-vi");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await TryEnterManualMutationGatesAsync(requestId, "save-vi"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration);
|
||||
if (!CanStartOperatorMutation(IsManualOperatorWriteQuarantined()))
|
||||
{
|
||||
ExitManualMutationGates();
|
||||
PostManualOperatorError(
|
||||
requestId,
|
||||
"save-vi",
|
||||
"PLAYOUT_ACTIVE",
|
||||
"Operator data cannot be changed unless playout is verifiably idle.",
|
||||
retryable: true,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
var mutationStarted = false;
|
||||
try
|
||||
{
|
||||
var currentSnapshot = await store.ReadSnapshotAsync(_lifetimeCancellation.Token);
|
||||
if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
currentSnapshot.RowVersion,
|
||||
expectedVersion,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
PostManualOperatorError(
|
||||
requestId,
|
||||
"save-vi",
|
||||
"STALE_DATA",
|
||||
"The VI manual list changed after it was read. Reload it before saving.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _manualOperatorWriteInFlight, 1);
|
||||
mutationStarted = true;
|
||||
var result = await store.WriteAsync(items, _lifetimeCancellation.Token);
|
||||
if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration))
|
||||
{
|
||||
QuarantineManualOperatorWrites(
|
||||
"VI 수동 목록 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요.");
|
||||
return;
|
||||
}
|
||||
PostViWriteResult(requestId, result, recovered: false);
|
||||
}
|
||||
catch (Exception exception) when (exception is
|
||||
ViTrustedManualDataException or OperationCanceledException)
|
||||
{
|
||||
if (_lifetimeCancellation.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mutationStarted)
|
||||
{
|
||||
PostManualOperatorError(
|
||||
requestId,
|
||||
"save-vi",
|
||||
"DATA_INVALID",
|
||||
"The current VI manual list could not be verified before saving.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var persisted = await store.ReadSnapshotAsync(CancellationToken.None);
|
||||
if (persisted.Items.SequenceEqual(items))
|
||||
{
|
||||
if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration))
|
||||
{
|
||||
QuarantineManualOperatorWrites(
|
||||
"VI 수동 목록 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요.");
|
||||
return;
|
||||
}
|
||||
PostViWriteResult(
|
||||
requestId,
|
||||
new ViTrustedManualWriteResult(persisted, Persisted: items.Count != 0),
|
||||
recovered: true);
|
||||
return;
|
||||
}
|
||||
|
||||
QuarantineManualOperatorWrites(
|
||||
"VI 수동 목록 저장 중 파일 내용이 달라져 결과를 확정할 수 없습니다. 파일을 직접 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요.");
|
||||
PostManualOperatorQuarantine(requestId, "save-vi");
|
||||
}
|
||||
catch
|
||||
{
|
||||
QuarantineManualOperatorWrites(
|
||||
"VI 수동 목록 저장 결과를 확인할 수 없습니다. 파일을 직접 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요.");
|
||||
PostManualOperatorQuarantine(requestId, "save-vi");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _manualOperatorWriteInFlight, 0);
|
||||
ExitManualMutationGates();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TryEnterManualMutationGatesAsync(
|
||||
string requestId,
|
||||
string operation)
|
||||
{
|
||||
if (!await _playoutCommandGate.WaitAsync(0))
|
||||
{
|
||||
PostManualOperatorError(
|
||||
requestId,
|
||||
operation,
|
||||
"PLAYOUT_BUSY",
|
||||
"송출 명령을 처리하는 동안 수동 데이터를 저장할 수 없습니다.",
|
||||
retryable: true,
|
||||
outcomeUnknown: false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!await _manualOperatorDataGate.WaitAsync(0))
|
||||
{
|
||||
_playoutCommandGate.Release();
|
||||
PostManualOperatorError(
|
||||
requestId,
|
||||
operation,
|
||||
"DATA_BUSY",
|
||||
"다른 수동 데이터 작업이 진행 중입니다.",
|
||||
retryable: true,
|
||||
outcomeUnknown: false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CanStartOperatorMutation(IsManualOperatorWriteQuarantined()))
|
||||
{
|
||||
ExitManualMutationGates();
|
||||
PostManualOperatorError(
|
||||
requestId,
|
||||
operation,
|
||||
"PLAYOUT_ACTIVE",
|
||||
"PREPARE 또는 송출 중에는 수동 데이터를 변경할 수 없습니다. TAKE OUT 후 저장하세요.",
|
||||
retryable: true,
|
||||
outcomeUnknown: false);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ExitManualMutationGates()
|
||||
{
|
||||
_manualOperatorDataGate.Release();
|
||||
_playoutCommandGate.Release();
|
||||
}
|
||||
|
||||
private static bool TryParseAudienceRequest(
|
||||
JsonElement payload,
|
||||
out string requestId,
|
||||
out S5025ManualAudience audience)
|
||||
{
|
||||
requestId = SafeManualOperatorRequestId(payload);
|
||||
audience = default;
|
||||
return payload.ValueKind == JsonValueKind.Object &&
|
||||
HasOnlyProperties(payload, "requestId", "audience") &&
|
||||
TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) &&
|
||||
TryParseAudience(GetString(payload, "audience"), out audience);
|
||||
}
|
||||
|
||||
private static string SafeManualOperatorRequestId(JsonElement payload) =>
|
||||
payload.ValueKind == JsonValueKind.Object &&
|
||||
TryGetSafeToken(
|
||||
payload,
|
||||
"requestId",
|
||||
MaximumPlayoutRequestIdLength,
|
||||
out var requestId)
|
||||
? requestId
|
||||
: string.Empty;
|
||||
|
||||
private static bool TryParseManualNetSellWrite(
|
||||
JsonElement payload,
|
||||
out string requestId,
|
||||
out S5025ManualAudience audience,
|
||||
out IReadOnlyList<S5025TrustedManualRow> rows)
|
||||
{
|
||||
requestId = SafeManualOperatorRequestId(payload);
|
||||
audience = default;
|
||||
rows = Array.Empty<S5025TrustedManualRow>();
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(payload, "requestId", "audience", "rows") ||
|
||||
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) ||
|
||||
!TryParseAudience(GetString(payload, "audience"), out audience) ||
|
||||
!payload.TryGetProperty("rows", out var rowsValue) ||
|
||||
rowsValue.ValueKind != JsonValueKind.Array ||
|
||||
rowsValue.GetArrayLength() != 5)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parsed = new List<S5025TrustedManualRow>(5);
|
||||
foreach (var row in rowsValue.EnumerateArray())
|
||||
{
|
||||
if (row.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(
|
||||
row,
|
||||
"leftName",
|
||||
"leftAmount",
|
||||
"rightName",
|
||||
"rightAmount") ||
|
||||
!TryGetManualValue(row, "leftName", out var leftName) ||
|
||||
!TryGetManualValue(row, "leftAmount", out var leftAmount) ||
|
||||
!TryGetManualValue(row, "rightName", out var rightName) ||
|
||||
!TryGetManualValue(row, "rightAmount", out var rightAmount))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
parsed.Add(new S5025TrustedManualRow(
|
||||
leftName,
|
||||
leftAmount,
|
||||
rightName,
|
||||
rightAmount));
|
||||
}
|
||||
|
||||
rows = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseViWrite(
|
||||
JsonElement payload,
|
||||
out string requestId,
|
||||
out string expectedVersion,
|
||||
out IReadOnlyList<ViTrustedManualItem> items)
|
||||
{
|
||||
requestId = SafeManualOperatorRequestId(payload);
|
||||
expectedVersion = string.Empty;
|
||||
items = Array.Empty<ViTrustedManualItem>();
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(payload, "requestId", "expectedVersion", "items") ||
|
||||
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) ||
|
||||
!TryGetViExpectedVersion(payload, out expectedVersion) ||
|
||||
!payload.TryGetProperty("items", out var itemsValue) ||
|
||||
itemsValue.ValueKind != JsonValueKind.Array ||
|
||||
itemsValue.GetArrayLength() > ViTrustedManualFileStore.MaximumItemCount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parsed = new List<ViTrustedManualItem>(itemsValue.GetArrayLength());
|
||||
foreach (var item in itemsValue.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(item, "code", "name") ||
|
||||
!TryGetManualValue(item, "code", out var code, allowEmpty: false, maximumLength: 64) ||
|
||||
!TryGetManualValue(item, "name", out var name, allowEmpty: false, maximumLength: 200) ||
|
||||
!string.Equals(code, code.Trim(), StringComparison.Ordinal) ||
|
||||
!string.Equals(name, name.Trim(), StringComparison.Ordinal) ||
|
||||
name.Contains(','))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
parsed.Add(new ViTrustedManualItem(code, name));
|
||||
}
|
||||
|
||||
items = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryGetViExpectedVersion(
|
||||
JsonElement payload,
|
||||
out string expectedVersion)
|
||||
{
|
||||
expectedVersion = string.Empty;
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
!payload.TryGetProperty("expectedVersion", out var value) ||
|
||||
value.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
expectedVersion = value.GetString() ?? string.Empty;
|
||||
return TrustedViManualReference.IsRowVersion(expectedVersion);
|
||||
}
|
||||
|
||||
private static bool TryGetManualValue(
|
||||
JsonElement payload,
|
||||
string propertyName,
|
||||
out string value,
|
||||
bool allowEmpty = true,
|
||||
int maximumLength = MaximumManualValueLength)
|
||||
{
|
||||
value = GetString(payload, propertyName) ?? string.Empty;
|
||||
return payload.TryGetProperty(propertyName, out var element) &&
|
||||
element.ValueKind == JsonValueKind.String &&
|
||||
(allowEmpty || value.Length > 0) &&
|
||||
value.Length <= maximumLength &&
|
||||
!value.Contains('^') &&
|
||||
!value.Any(char.IsControl);
|
||||
}
|
||||
|
||||
private static bool TryParseAudience(
|
||||
string? value,
|
||||
out S5025ManualAudience audience)
|
||||
{
|
||||
audience = value switch
|
||||
{
|
||||
"INDIVIDUAL" => S5025ManualAudience.Individual,
|
||||
"FOREIGN" => S5025ManualAudience.Foreign,
|
||||
"INSTITUTION" => S5025ManualAudience.Institution,
|
||||
_ => default
|
||||
};
|
||||
return value is "INDIVIDUAL" or "FOREIGN" or "INSTITUTION";
|
||||
}
|
||||
|
||||
private static string ToWireValue(S5025ManualAudience audience) => audience switch
|
||||
{
|
||||
S5025ManualAudience.Individual => "INDIVIDUAL",
|
||||
S5025ManualAudience.Foreign => "FOREIGN",
|
||||
S5025ManualAudience.Institution => "INSTITUTION",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(audience))
|
||||
};
|
||||
|
||||
private void PostManualNetSellWriteResult(
|
||||
string requestId,
|
||||
S5025ManualAudience audience,
|
||||
bool recovered) =>
|
||||
PostMessage("manual-net-sell-save-result", new
|
||||
{
|
||||
requestId,
|
||||
audience = ToWireValue(audience),
|
||||
saved = true,
|
||||
recovered
|
||||
});
|
||||
|
||||
private void PostViWriteResult(
|
||||
string requestId,
|
||||
ViTrustedManualWriteResult result,
|
||||
bool recovered) =>
|
||||
PostMessage("vi-manual-list-save-result", new
|
||||
{
|
||||
requestId,
|
||||
saved = true,
|
||||
recovered,
|
||||
persisted = result.Persisted,
|
||||
itemCount = result.Snapshot.Items.Count,
|
||||
pageCount = result.Snapshot.Items.Count == 0
|
||||
? 0
|
||||
: (result.Snapshot.Items.Count + 4) / 5,
|
||||
version = result.Snapshot.RowVersion
|
||||
});
|
||||
|
||||
private void PostManualOperatorUnavailable(string requestId, string operation) =>
|
||||
PostManualOperatorError(
|
||||
requestId,
|
||||
operation,
|
||||
"DATA_UNAVAILABLE",
|
||||
_manualOperatorDataInitializationError ??
|
||||
"수동 송출 데이터 저장소를 사용할 수 없습니다.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false);
|
||||
|
||||
private void PostManualOperatorQuarantine(string requestId, string operation) =>
|
||||
PostManualOperatorError(
|
||||
requestId,
|
||||
operation,
|
||||
"OUTCOME_UNKNOWN",
|
||||
_manualOperatorWriteQuarantineMessage ??
|
||||
"이전 저장 결과가 불명확하여 앱을 다시 시작하기 전까지 저장할 수 없습니다.",
|
||||
retryable: false,
|
||||
outcomeUnknown: true);
|
||||
|
||||
private void PostManualOperatorError(
|
||||
string requestId,
|
||||
string operation,
|
||||
string code,
|
||||
string message,
|
||||
bool retryable,
|
||||
bool outcomeUnknown) =>
|
||||
PostMessage("manual-operator-data-error", new
|
||||
{
|
||||
requestId,
|
||||
operation,
|
||||
code,
|
||||
message,
|
||||
retryable,
|
||||
outcomeUnknown
|
||||
});
|
||||
|
||||
private bool IsManualOperatorWriteQuarantined() =>
|
||||
Volatile.Read(ref _manualOperatorWriteQuarantined) != 0 ||
|
||||
IsOperatorMutationQuarantined();
|
||||
|
||||
private void QuarantineManualOperatorWrites(string message)
|
||||
{
|
||||
LatchOperatorMutationQuarantine(message);
|
||||
_manualOperatorWriteQuarantineMessage = message;
|
||||
Interlocked.Exchange(ref _manualOperatorWriteQuarantined, 1);
|
||||
}
|
||||
|
||||
private void ShutdownManualOperatorDataRuntime()
|
||||
{
|
||||
InvalidateManualOperatorBrowserRequests();
|
||||
var netSellStore = Interlocked.Exchange(ref _manualNetSellStore, null);
|
||||
var viStore = Interlocked.Exchange(ref _viManualListStore, null);
|
||||
if (netSellStore is null && viStore is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = DisposeManualOperatorStoresWhenIdleAsync(netSellStore, viStore);
|
||||
}
|
||||
|
||||
private async Task DisposeManualOperatorStoresWhenIdleAsync(
|
||||
S5025TrustedManualFileStore? netSellStore,
|
||||
ViTrustedManualFileStore? viStore)
|
||||
{
|
||||
await _manualOperatorDataGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
viStore?.Dispose();
|
||||
netSellStore?.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_manualOperatorDataGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void InvalidateManualOperatorBrowserRequests()
|
||||
{
|
||||
Interlocked.Increment(ref _manualOperatorBrowserGeneration);
|
||||
if (Volatile.Read(ref _manualOperatorWriteInFlight) != 0)
|
||||
{
|
||||
QuarantineManualOperatorWrites(
|
||||
"수동 데이터 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user