diff --git a/Tornado3_2026Election/Domain/DataSourceConnectionState.cs b/Tornado3_2026Election/Domain/DataSourceConnectionState.cs new file mode 100644 index 0000000..b7c16fc --- /dev/null +++ b/Tornado3_2026Election/Domain/DataSourceConnectionState.cs @@ -0,0 +1,10 @@ +namespace Tornado3_2026Election.Domain; + +public enum DataSourceConnectionState +{ + Waiting, + Receiving, + Connected, + Warning, + Disconnected +} diff --git a/Tornado3_2026Election/ViewModels/DataViewModel.cs b/Tornado3_2026Election/ViewModels/DataViewModel.cs index e2167bb..733890e 100644 --- a/Tornado3_2026Election/ViewModels/DataViewModel.cs +++ b/Tornado3_2026Election/ViewModels/DataViewModel.cs @@ -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 _districtCodeMap = new(StringComparer.Ordinal); private readonly Dictionary _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() + : 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) diff --git a/Tornado3_2026Election/ViewModels/MainViewModel.cs b/Tornado3_2026Election/ViewModels/MainViewModel.cs index 44f8def..986a443 100644 --- a/Tornado3_2026Election/ViewModels/MainViewModel.cs +++ b/Tornado3_2026Election/ViewModels/MainViewModel.cs @@ -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(); }