데이터 소스 자동 수신 상태 분리

This commit is contained in:
2026-05-14 13:10:46 +09:00
parent 7e3f496ae4
commit dc6c670c8e
3 changed files with 295 additions and 70 deletions

View File

@@ -0,0 +1,10 @@
namespace Tornado3_2026Election.Domain;
public enum DataSourceConnectionState
{
Waiting,
Receiving,
Connected,
Warning,
Disconnected
}

View File

@@ -29,6 +29,8 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private const string PanseDemocraticPartyLabel = "더불어민주당";
private const string PansePeoplePowerPartyLabel = "국민의힘";
private const string PanseOtherPartyLabel = "무·기타";
private const string SbsProbeElectionType = "광역단체장";
private const string MbcCniProbeElectionType = "광역의원";
public const int FixedPollingIntervalSeconds = 60;
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
@@ -138,6 +140,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private readonly SbsElectionApiClient _apiClient;
private readonly PreElectionHistoryService _preElectionHistoryService;
private readonly CareerPromiseService _careerPromiseService;
private readonly SemaphoreSlim _dataSourceRefreshLock = new(1, 1);
private readonly Dictionary<string, string> _districtCodeMap = new(StringComparer.Ordinal);
private readonly Dictionary<string, SbsElectionApiClient.DistrictSelectionOption> _districtOptionMap = new(StringComparer.Ordinal);
private CancellationTokenSource? _pollingCts;
@@ -150,6 +153,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private bool _isApplyingRefreshResult;
private DateTimeOffset _lastRefreshAt = DateTimeOffset.MinValue;
private DateTimeOffset _lastManualRefreshAt = DateTimeOffset.MinValue;
private DataSourceConnectionState _sbsDataSourceState = DataSourceConnectionState.Waiting;
private DataSourceConnectionState _mbcCniDataSourceState = DataSourceConnectionState.Waiting;
private DateTimeOffset _sbsDataSourceLastRefreshAt = DateTimeOffset.MinValue;
private DateTimeOffset _mbcCniDataSourceLastRefreshAt = DateTimeOffset.MinValue;
private string _sbsDataSourceDetail = "아직 자동 수신 전입니다.";
private string _mbcCniDataSourceDetail = "아직 자동 수신 전입니다.";
private string _electionType = "광역단체장";
private string _districtName = "부산광역시";
private string _districtCode = "26";
@@ -332,6 +341,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
if (SetProperty(ref _broadcastPhase, value))
{
OnPropertyChanged(nameof(StatusText), nameof(HasLiveDataSignal));
OnPropertyChanged(nameof(HasAnyLiveDataSignal));
NotifyModePresentationChanged();
}
}
@@ -645,6 +655,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
{
OnPropertyChanged(nameof(StatusText), nameof(PollingCountdownText), nameof(PollingModeLabel), nameof(PollingStateDetail));
OnPropertyChanged(nameof(HasLiveDataSignal));
OnPropertyChanged(nameof(HasAnyLiveDataSignal));
}
}
}
@@ -678,10 +689,27 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
{
OnPropertyChanged(nameof(StatusText), nameof(LastRefreshDisplay));
OnPropertyChanged(nameof(HasLiveDataSignal));
OnPropertyChanged(nameof(HasAnyLiveDataSignal));
}
}
}
public DataSourceConnectionState SbsDataSourceState => _sbsDataSourceState;
public DataSourceConnectionState MbcCniDataSourceState => _mbcCniDataSourceState;
public DateTimeOffset SbsDataSourceLastRefreshAt => _sbsDataSourceLastRefreshAt;
public DateTimeOffset MbcCniDataSourceLastRefreshAt => _mbcCniDataSourceLastRefreshAt;
public string SbsDataSourceSummary => ResolveDataSourceSummary(SbsDataSourceState);
public string MbcCniDataSourceSummary => ResolveDataSourceSummary(MbcCniDataSourceState);
public string SbsDataSourceDetail => _sbsDataSourceDetail;
public string MbcCniDataSourceDetail => _mbcCniDataSourceDetail;
public string ElectionType
{
get => _electionType;
@@ -692,6 +720,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
if (SetProperty(ref _electionType, normalizedValue))
{
OnPropertyChanged(nameof(StatusText), nameof(CareerPromiseContextText), nameof(HasLiveDataSignal));
OnPropertyChanged(nameof(HasAnyLiveDataSignal));
NotifyModePresentationChanged();
RefreshPreElectionHistoryPresentation();
_ = RefreshDistrictOptionsForElectionTypeAsync();
@@ -911,6 +940,11 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
LastRefreshAt != DateTimeOffset.MinValue &&
string.IsNullOrWhiteSpace(_lastRefreshWarningMessage));
public bool HasAnyLiveDataSignal =>
HasLiveDataSignal ||
SbsDataSourceState == DataSourceConnectionState.Connected ||
MbcCniDataSourceState == DataSourceConnectionState.Connected;
public string PollingModeLabel
{
get
@@ -984,6 +1018,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
}
_pollingCts = new CancellationTokenSource();
_ = RefreshAllDataSourcesAsync(_pollingCts.Token);
_ = RunPollingLoopAsync(_pollingCts.Token);
}
@@ -2501,6 +2536,218 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
_lastRefreshWarningMessage = normalized;
OnPropertyChanged(nameof(StatusText), nameof(HasLiveDataSignal));
OnPropertyChanged(nameof(HasAnyLiveDataSignal));
}
private async Task RefreshAllDataSourcesAsync(CancellationToken cancellationToken)
{
if (!await _dataSourceRefreshLock.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return;
}
try
{
await RefreshSbsDataSourceAsync(cancellationToken).ConfigureAwait(false);
await RefreshMbcCniDataSourceAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_dataSourceRefreshLock.Release();
}
}
private async Task RefreshSbsDataSourceAsync(CancellationToken cancellationToken)
{
SetDataSourceStatus(
isMbcCni: false,
DataSourceConnectionState.Receiving,
"광역단체장 개표 데이터 수신 중");
try
{
var districts = await _apiClient
.GetDistrictOptionsAsync(SbsProbeElectionType, cancellationToken)
.ConfigureAwait(false);
if (districts.Count == 0)
{
SetDataSourceStatus(
isMbcCni: false,
DataSourceConnectionState.Warning,
"광역단체장 선거구 목록이 비어 있습니다.");
_logService.Warning("SBS 데이터 상태 확인: 광역단체장 선거구 목록이 비어 있습니다.");
return;
}
var snapshots = await _apiClient
.GetCountingSnapshotsAsync(SbsProbeElectionType, districts, cancellationToken)
.ConfigureAwait(false);
if (snapshots.Count == 0)
{
SetDataSourceStatus(
isMbcCni: false,
DataSourceConnectionState.Warning,
$"광역단체장 선거구 {districts.Count}개 확인, 개표 데이터 없음");
_logService.Warning($"SBS 데이터 상태 확인: 광역단체장 선거구 {districts.Count}개 확인, 개표 데이터가 없습니다.");
return;
}
var receivedAt = snapshots
.Select(snapshot => snapshot.ReceivedAt == default ? DateTimeOffset.Now : snapshot.ReceivedAt)
.DefaultIfEmpty(DateTimeOffset.Now)
.Max();
SetDataSourceStatus(
isMbcCni: false,
DataSourceConnectionState.Connected,
$"광역단체장 개표 {snapshots.Count}/{districts.Count}개 수신 / {receivedAt:HH:mm:ss}",
receivedAt);
_logService.Info($"SBS 데이터 상태 갱신 완료. 광역단체장 개표 {snapshots.Count}/{districts.Count}개 수신");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
SetDataSourceStatus(
isMbcCni: false,
DataSourceConnectionState.Disconnected,
CreateDataSourceFailureDetail(ex.Message, SbsDataSourceLastRefreshAt));
_logService.Warning($"SBS 데이터 상태 확인 실패: {ex.Message}");
}
}
private async Task RefreshMbcCniDataSourceAsync(CancellationToken cancellationToken)
{
SetDataSourceStatus(
isMbcCni: true,
DataSourceConnectionState.Receiving,
"광역의원 개표·의석표 데이터 수신 중");
try
{
var scopedRegionFilters = ResolveMbcCniProbeRegionScope();
var districts = await _apiClient
.GetDistrictOptionsAsync(MbcCniProbeElectionType, scopedRegionFilters, cancellationToken)
.ConfigureAwait(false);
var seatRegions = await _apiClient
.GetCouncilSeatRegionOptionsAsync(MbcCniProbeElectionType, scopedRegionFilters, cancellationToken)
.ConfigureAwait(false);
if (districts.Count == 0 && seatRegions.Count == 0)
{
SetDataSourceStatus(
isMbcCni: true,
DataSourceConnectionState.Warning,
"광역의원 개표·의석표 데이터가 비어 있습니다.");
_logService.Warning("MBC CNI 데이터 상태 확인: 광역의원 개표·의석표 데이터가 비어 있습니다.");
return;
}
var snapshots = districts.Count == 0
? Array.Empty<SbsElectionApiClient.SbsElectionRefreshResult>()
: await _apiClient
.GetCountingSnapshotsAsync(MbcCniProbeElectionType, districts, cancellationToken)
.ConfigureAwait(false);
var receivedAt = snapshots
.Select(snapshot => snapshot.ReceivedAt == default ? DateTimeOffset.Now : snapshot.ReceivedAt)
.DefaultIfEmpty(DateTimeOffset.Now)
.Max();
if (snapshots.Count == 0 || seatRegions.Count == 0)
{
SetDataSourceStatus(
isMbcCni: true,
DataSourceConnectionState.Warning,
$"광역의원 개표 {snapshots.Count}/{districts.Count}개, 의석표 {seatRegions.Count}개 확인",
receivedAt);
_logService.Warning($"MBC CNI 데이터 상태 확인: 광역의원 개표 {snapshots.Count}/{districts.Count}개, 의석표 {seatRegions.Count}개 확인");
return;
}
SetDataSourceStatus(
isMbcCni: true,
DataSourceConnectionState.Connected,
$"광역의원 개표 {snapshots.Count}/{districts.Count}개 / 의석표 {seatRegions.Count}개 수신 / {receivedAt:HH:mm:ss}",
receivedAt);
_logService.Info($"MBC CNI 데이터 상태 갱신 완료. 광역의원 개표 {snapshots.Count}/{districts.Count}개, 의석표 {seatRegions.Count}개 수신");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
SetDataSourceStatus(
isMbcCni: true,
DataSourceConnectionState.Disconnected,
CreateDataSourceFailureDetail(ex.Message, MbcCniDataSourceLastRefreshAt));
_logService.Warning($"MBC CNI 데이터 상태 확인 실패: {ex.Message}");
}
}
private string[] ResolveMbcCniProbeRegionScope()
{
var scopedRegionFilters = ResolveApiDistrictRegionScope(MbcCniProbeElectionType)
.Where(region => !string.IsNullOrWhiteSpace(region))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return scopedRegionFilters.Length > 0
? scopedRegionFilters
: DefaultDistrictOptions.Select(option => option.RegionName).ToArray();
}
private void SetDataSourceStatus(
bool isMbcCni,
DataSourceConnectionState state,
string detail,
DateTimeOffset? receivedAt = null)
{
if (isMbcCni)
{
_mbcCniDataSourceState = state;
_mbcCniDataSourceDetail = string.IsNullOrWhiteSpace(detail) ? ResolveDataSourceSummary(state) : detail;
if (receivedAt.HasValue)
{
_mbcCniDataSourceLastRefreshAt = receivedAt.Value;
}
OnPropertyChanged(
nameof(MbcCniDataSourceState),
nameof(MbcCniDataSourceSummary),
nameof(MbcCniDataSourceDetail),
nameof(MbcCniDataSourceLastRefreshAt),
nameof(HasAnyLiveDataSignal));
return;
}
_sbsDataSourceState = state;
_sbsDataSourceDetail = string.IsNullOrWhiteSpace(detail) ? ResolveDataSourceSummary(state) : detail;
if (receivedAt.HasValue)
{
_sbsDataSourceLastRefreshAt = receivedAt.Value;
}
OnPropertyChanged(
nameof(SbsDataSourceState),
nameof(SbsDataSourceSummary),
nameof(SbsDataSourceDetail),
nameof(SbsDataSourceLastRefreshAt),
nameof(HasAnyLiveDataSignal));
}
private static string ResolveDataSourceSummary(DataSourceConnectionState state)
{
return state switch
{
DataSourceConnectionState.Receiving => "수신 중",
DataSourceConnectionState.Connected => "연결됨",
DataSourceConnectionState.Warning => "확인 필요",
DataSourceConnectionState.Disconnected => "끊김",
_ => "대기"
};
}
private static string CreateDataSourceFailureDetail(string message, DateTimeOffset lastRefreshAt)
{
var detail = string.IsNullOrWhiteSpace(message)
? "수신 실패"
: $"수신 실패: {message}";
return lastRefreshAt == DateTimeOffset.MinValue
? detail
: $"{detail} / 마지막 성공 {lastRefreshAt:HH:mm:ss}";
}
public void Dispose()
@@ -2526,6 +2773,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
PollingCountdownSeconds = remainingSeconds;
}
await RefreshAllDataSourcesAsync(cancellationToken).ConfigureAwait(false);
await RefreshAsync(isManualRequest: false);
}
catch (OperationCanceledException)

View File

@@ -24,10 +24,12 @@ public sealed class MainViewModel : ObservableObject
private static readonly Brush DisconnectedStatusBrush = new SolidColorBrush(Colors.OrangeRed);
private static readonly Brush WaitingStatusBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 148, 163, 184));
private static readonly Brush ReceivingStatusBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 56, 189, 248));
private static readonly Brush WarningStatusBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 250, 204, 21));
private static readonly Brush ConnectedCardBackgroundBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 8, 52, 34));
private static readonly Brush DisconnectedCardBackgroundBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 64, 24, 28));
private static readonly Brush WaitingCardBackgroundBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 19, 35, 56));
private static readonly Brush ReceivingCardBackgroundBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 12, 42, 66));
private static readonly Brush WarningCardBackgroundBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 58, 48, 16));
private static readonly Brush DataReceivingNavigationBrush = new SolidColorBrush(Colors.LimeGreen);
private static readonly Brush DataWaitingNavigationBrush = new SolidColorBrush(Colors.White);
private static readonly TimeSpan AutomaticSaveDelay = TimeSpan.FromMilliseconds(500);
@@ -428,7 +430,7 @@ public sealed class MainViewModel : ObservableObject
public string CgIntegrationOperatorMessage => IsCgConnected ? "송출 가능" : "연결 확인 필요";
public Brush DataNavigationIconBrush => Data.HasLiveDataSignal
public Brush DataNavigationIconBrush => Data.HasAnyLiveDataSignal
? DataReceivingNavigationBrush
: DataWaitingNavigationBrush;
@@ -546,89 +548,47 @@ public sealed class MainViewModel : ObservableObject
private Brush ResolveDataConnectionBrush(bool isMbcCni)
{
if (!IsCurrentDataSource(isMbcCni))
return ResolveDataConnectionState(isMbcCni) switch
{
return WaitingStatusBrush;
}
if (Data.IsRefreshing)
{
return ReceivingStatusBrush;
}
if (Data.HasLiveDataSignal)
{
return ConnectedStatusBrush;
}
return DisconnectedStatusBrush;
DataSourceConnectionState.Receiving => ReceivingStatusBrush,
DataSourceConnectionState.Connected => ConnectedStatusBrush,
DataSourceConnectionState.Warning => WarningStatusBrush,
DataSourceConnectionState.Disconnected => DisconnectedStatusBrush,
_ => WaitingStatusBrush
};
}
private Brush ResolveDataConnectionCardBackgroundBrush(bool isMbcCni)
{
if (!IsCurrentDataSource(isMbcCni))
return ResolveDataConnectionState(isMbcCni) switch
{
return WaitingCardBackgroundBrush;
}
if (Data.IsRefreshing)
{
return ReceivingCardBackgroundBrush;
}
return Data.HasLiveDataSignal
? ConnectedCardBackgroundBrush
: DisconnectedCardBackgroundBrush;
DataSourceConnectionState.Receiving => ReceivingCardBackgroundBrush,
DataSourceConnectionState.Connected => ConnectedCardBackgroundBrush,
DataSourceConnectionState.Warning => WarningCardBackgroundBrush,
DataSourceConnectionState.Disconnected => DisconnectedCardBackgroundBrush,
_ => WaitingCardBackgroundBrush
};
}
private string ResolveDataConnectionSummary(bool isMbcCni)
{
if (!IsCurrentDataSource(isMbcCni))
{
return "대기";
}
if (Data.IsRefreshing)
{
return "수신 중";
}
if (Data.HasLiveDataSignal)
{
return "연결됨";
}
if (!Data.IsCurrentApiSelectionSupported)
{
return "미지원";
}
return Data.LastRefreshAt == DateTimeOffset.MinValue ? "미수신" : "확인 필요";
return isMbcCni
? Data.MbcCniDataSourceSummary
: Data.SbsDataSourceSummary;
}
private string ResolveDataConnectionDetail(bool isMbcCni)
{
if (!IsCurrentDataSource(isMbcCni))
{
return isMbcCni
? "광역의원 포함 MBC CNI 데이터"
: "광역단체장 포함 SBS 데이터";
}
return $"{Data.ElectionType} / {Data.BroadcastPhaseLabel} / {Data.StatusText}";
return isMbcCni
? Data.MbcCniDataSourceDetail
: Data.SbsDataSourceDetail;
}
private bool IsCurrentDataSource(bool isMbcCni)
private DataSourceConnectionState ResolveDataConnectionState(bool isMbcCni)
{
return IsMbcCniDataSource(Data.ElectionType) == isMbcCni;
}
private static bool IsMbcCniDataSource(string electionType)
{
return string.Equals(electionType, "광역의원", StringComparison.Ordinal) ||
string.Equals(electionType, "기초의원", StringComparison.Ordinal) ||
string.Equals(electionType, "비례대표광역의원", StringComparison.Ordinal) ||
string.Equals(electionType, "비례대표기초의원", StringComparison.Ordinal);
return isMbcCni
? Data.MbcCniDataSourceState
: Data.SbsDataSourceState;
}
public void Navigate(string tag)
@@ -919,18 +879,25 @@ public sealed class MainViewModel : ObservableObject
OnPropertyChanged(nameof(HeaderStatus));
}
if (args.PropertyName is nameof(DataViewModel.HasLiveDataSignal))
if (args.PropertyName is nameof(DataViewModel.HasAnyLiveDataSignal))
{
OnPropertyChanged(nameof(DataNavigationIconBrush));
}
if (args.PropertyName is nameof(DataViewModel.HasLiveDataSignal)
if (args.PropertyName is nameof(DataViewModel.HasAnyLiveDataSignal)
or nameof(DataViewModel.HasLiveDataSignal)
or nameof(DataViewModel.IsRefreshing)
or nameof(DataViewModel.LastRefreshAt)
or nameof(DataViewModel.StatusText)
or nameof(DataViewModel.ElectionType)
or nameof(DataViewModel.BroadcastPhase)
or nameof(DataViewModel.IsPollingEnabled))
or nameof(DataViewModel.IsPollingEnabled)
or nameof(DataViewModel.SbsDataSourceState)
or nameof(DataViewModel.SbsDataSourceDetail)
or nameof(DataViewModel.SbsDataSourceSummary)
or nameof(DataViewModel.MbcCniDataSourceState)
or nameof(DataViewModel.MbcCniDataSourceDetail)
or nameof(DataViewModel.MbcCniDataSourceSummary))
{
NotifyDataConnectionCardsChanged();
}