Files
MBN_STOCK_WEBVIEW/MainWindow.ManualLists.cs

930 lines
36 KiB
C#

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 LegacyManualOperatorDataImporter? _legacyManualOperatorDataImporter;
private string? _manualOperatorDataInitializationError;
private string? _manualOperatorDataInitializationCode;
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))
{
_manualOperatorDataInitializationCode = "UNSAFE_CONFIGURATION";
_manualOperatorDataInitializationError =
"외부 수동 데이터 경로는 TOCTOU 안전성을 보장할 수 없어 비활성화되었습니다. 앱 전용 로컬 운영 데이터 폴더를 사용하세요.";
return;
}
var directory = TrustedManualDirectory.PreparePrivateWritableDirectory(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
"OperatorData"));
var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(directory);
if (!inspection.IsReady)
{
(_manualOperatorDataInitializationCode,
_manualOperatorDataInitializationError) = inspection.State switch
{
LegacyManualOperatorDataRuntimeState.InterruptedImport => (
"IMPORT_INTERRUPTED",
"이전 원본 수동 데이터 가져오기가 중단되었습니다. FSell/VI 파일은 열지 않았습니다. 다시 시작하기 전에 진행 표식, 완료 표식, 데이터 파일 4개를 확인하세요."),
_ => (
"IMPORTED_STATE_INVALID",
"원본 수동 데이터 완료 표식과 파일 4개가 검증된 한 세트를 이루지 않습니다. FSell/VI 파일은 열지 않았습니다. 다시 시작하기 전에 앱 전용 운영 데이터 폴더를 확인하세요.")
};
return;
}
_manualNetSellStore = new S5025TrustedManualFileStore(directory);
_viManualListStore = new ViTrustedManualFileStore(directory);
_legacyManualOperatorDataImporter = new LegacyManualOperatorDataImporter(
directory,
new FixedLegacyManualOperatorDataSource());
}
catch
{
_legacyManualOperatorDataImporter?.Dispose();
_viManualListStore?.Dispose();
_manualNetSellStore?.Dispose();
_legacyManualOperatorDataImporter = null;
_viManualListStore = null;
_manualNetSellStore = null;
_manualOperatorDataInitializationCode = "INITIALIZATION_FAILED";
_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,
code = _manualOperatorDataInitializationCode ?? "READY",
writeQuarantined = IsManualOperatorWriteQuarantined(),
message = IsManualOperatorWriteQuarantined()
? GetOperatorMutationQuarantineMessage(
_manualOperatorWriteQuarantineMessage ??
ManualOperatorCorrelationQuarantineMessage)
: _manualOperatorDataInitializationError
});
}
private async Task HandleLegacyManualOperatorImportAsync(JsonElement payload)
{
const string operation = "import-legacy-manual-data";
if (payload.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(payload, "requestId") ||
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId))
{
PostManualOperatorError(
SafeManualOperatorRequestId(payload),
operation,
"INVALID_REQUEST",
"올바르지 않은 원본 수동 데이터 가져오기 요청입니다.",
retryable: false,
outcomeUnknown: false);
return;
}
var importer = _legacyManualOperatorDataImporter;
if (importer is null || _manualNetSellStore is null || _viManualListStore is null)
{
PostManualOperatorUnavailable(requestId, operation);
return;
}
if (IsManualOperatorWriteQuarantined())
{
PostManualOperatorQuarantine(requestId, operation);
return;
}
if (!await TryEnterManualMutationGatesAsync(requestId, operation))
{
return;
}
var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration);
Interlocked.Exchange(ref _manualOperatorWriteInFlight, 1);
try
{
var result = await importer.ImportAsync(_lifetimeCancellation.Token);
if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration))
{
QuarantineManualOperatorWrites(
"원본 수동 데이터 가져오기 중 화면 상관관계가 사라졌습니다. 가져오기 marker와 4개 파일을 확인하고 앱을 다시 시작하기 전까지 작업을 반복하지 마세요.");
return;
}
PostMessage("legacy-manual-operator-import-result", new
{
requestId,
markerVersion = result.MarkerVersion,
sourceSha256 = result.SourceSha256,
importedAt = result.ImportedAt,
netSellRowCount = result.NetSellRowCount,
viItemCount = result.ViItemCount
});
}
catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested)
{
// A pre-commit cancellation creates nothing. Once commit begins the
// importer completes or rolls back without observing cancellation.
}
catch (LegacyManualOperatorImportException exception)
{
if (exception.OutcomeUnknown)
{
QuarantineManualOperatorWrites(
"원본 수동 데이터 가져오기를 안전하게 되돌렸는지 확인할 수 없습니다. 4개 파일과 marker를 직접 확인하고 앱을 다시 시작하기 전까지 작업을 반복하지 마세요.");
PostManualOperatorQuarantine(requestId, operation);
return;
}
var (code, message) = exception.Failure switch
{
LegacyManualOperatorImportFailure.SourceUnavailable => (
"SOURCE_UNAVAILABLE",
"원본 프로젝트의 FSell/VI 데이터 4개 파일을 찾을 수 없습니다."),
LegacyManualOperatorImportFailure.SourceInvalid => (
"SOURCE_INVALID",
"원본 FSell/VI 데이터가 CP949 원본 형식 또는 크기 제한을 충족하지 않습니다."),
LegacyManualOperatorImportFailure.AlreadyImported => (
"ALREADY_IMPORTED",
"이 앱 전용 폴더에는 이미 완료 marker가 있어 다시 가져오지 않습니다."),
LegacyManualOperatorImportFailure.DestinationNotEmpty => (
"DESTINATION_NOT_EMPTY",
"앱 전용 폴더에 기존 FSell/VI 데이터가 있어 아무 파일도 덮어쓰지 않았습니다."),
LegacyManualOperatorImportFailure.InterruptedImport => (
"IMPORT_INTERRUPTED",
"이전 가져오기의 진행 표식이 남아 있습니다. 완료 표식과 파일 4개를 확인하기 전에는 다시 시작하거나 반복하지 마세요."),
_ => (
"IMPORT_FAILED",
"원본 수동 데이터 가져오기에 실패했고 생성된 파일은 되돌렸습니다. 자동으로 재시도하지 않습니다.")
};
PostManualOperatorError(
requestId,
operation,
code,
message,
retryable: false,
outcomeUnknown: false);
}
catch
{
QuarantineManualOperatorWrites(
"원본 수동 데이터 가져오기 결과를 확인할 수 없습니다. 4개 파일과 marker를 직접 확인하고 앱을 다시 시작하기 전까지 작업을 반복하지 마세요.");
PostManualOperatorQuarantine(requestId, operation);
}
finally
{
Interlocked.Exchange(ref _manualOperatorWriteInFlight, 0);
ExitManualMutationGates();
}
}
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,
_manualOperatorDataInitializationCode ?? "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);
var importer = Interlocked.Exchange(ref _legacyManualOperatorDataImporter, null);
if (netSellStore is null && viStore is null && importer is null)
{
return;
}
_ = DisposeManualOperatorStoresWhenIdleAsync(netSellStore, viStore, importer);
}
private async Task DisposeManualOperatorStoresWhenIdleAsync(
S5025TrustedManualFileStore? netSellStore,
ViTrustedManualFileStore? viStore,
LegacyManualOperatorDataImporter? importer)
{
await _manualOperatorDataGate.WaitAsync().ConfigureAwait(false);
try
{
importer?.Dispose();
viStore?.Dispose();
netSellStore?.Dispose();
}
finally
{
_manualOperatorDataGate.Release();
}
}
private void InvalidateManualOperatorBrowserRequests()
{
Interlocked.Increment(ref _manualOperatorBrowserGeneration);
if (Volatile.Read(ref _manualOperatorWriteInFlight) != 0)
{
QuarantineManualOperatorWrites(
"수동 데이터 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요.");
}
}
}