Files
Tornado3_2026Election/Tornado3_2026Election/Services/SbsElectionApiClient.cs
2026-04-17 00:39:25 +09:00

790 lines
29 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public sealed class SbsElectionApiClient : IDisposable
{
private static readonly Uri BaseUri = new("http://202.31.153.141:8421/");
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString
};
private static readonly IReadOnlyDictionary<string, SbsElectionConfiguration> ElectionConfigurations =
new Dictionary<string, SbsElectionConfiguration>(StringComparer.Ordinal)
{
["광역단체장"] = new SbsElectionConfiguration(3, true),
["교육감"] = new SbsElectionConfiguration(11, false),
["기초단체장"] = new SbsElectionConfiguration(4, false)
};
private static readonly IReadOnlyDictionary<string, string> FullRegionNames =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["서울"] = "서울특별시",
["부산"] = "부산광역시",
["대구"] = "대구광역시",
["인천"] = "인천광역시",
["광주"] = "광주광역시",
["대전"] = "대전광역시",
["울산"] = "울산광역시",
["세종"] = "세종특별자치시",
["경기"] = "경기도",
["강원"] = "강원특별자치도",
["충북"] = "충청북도",
["충남"] = "충청남도",
["전북"] = "전북특별자치도",
["전남"] = "전라남도",
["경북"] = "경상북도",
["경남"] = "경상남도",
["제주"] = "제주특별자치도"
};
private readonly HttpClient _httpClient;
private readonly bool _disposeHttpClient;
private IReadOnlyList<SbsRegionInfo>? _sidoRegions;
private readonly Dictionary<int, IReadOnlyList<SbsRegionInfo>> _districtRegions = new();
public SbsElectionApiClient(HttpClient? httpClient = null)
{
_httpClient = httpClient ?? new HttpClient { BaseAddress = BaseUri, Timeout = TimeSpan.FromSeconds(10) };
_disposeHttpClient = httpClient is null;
}
public async Task<SbsElectionRefreshResult> RefreshAsync(
BroadcastPhase phase,
string electionType,
string districtName,
string districtCode,
CancellationToken cancellationToken)
{
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration))
{
throw new InvalidOperationException(
$"'{electionType}'은 현재 SBS API 실연동 범위에 없습니다. 현재는 광역단체장, 교육감, 기초단체장까지만 연결되어 있습니다.");
}
return phase switch
{
BroadcastPhase.PreElection => await RefreshTurnoutAsync(configuration, districtName, districtCode, cancellationToken).ConfigureAwait(false),
BroadcastPhase.Counting => await RefreshCountingAsync(configuration, districtName, districtCode, cancellationToken).ConfigureAwait(false),
_ => throw new InvalidOperationException($"지원하지 않는 방송 단계입니다: {phase}")
};
}
public async Task<IReadOnlyList<DistrictSelectionOption>> GetDistrictOptionsAsync(
string electionType,
CancellationToken cancellationToken)
{
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration))
{
return Array.Empty<DistrictSelectionOption>();
}
var regions = await GetElectionDistrictRegionsAsync(configuration.SungerType, cancellationToken).ConfigureAwait(false);
return regions
.Select(region => CreateDistrictSelectionOption(configuration.SungerType, region))
.Where(option => !string.IsNullOrWhiteSpace(option.DisplayName))
.OrderBy(option => option.RegionName, StringComparer.Ordinal)
.ThenBy(option => option.DistrictName, StringComparer.Ordinal)
.ToArray();
}
public async Task<IReadOnlyList<CountingOverviewItem>> GetCountingOverviewAsync(
string electionType,
IReadOnlyList<DistrictSelectionOption> districts,
CancellationToken cancellationToken)
{
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) || districts.Count == 0)
{
return Array.Empty<CountingOverviewItem>();
}
var requestedDistricts = districts
.Where(district => !string.IsNullOrWhiteSpace(district.DistrictCode))
.GroupBy(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.ToArray();
if (requestedDistricts.Length == 0)
{
return Array.Empty<CountingOverviewItem>();
}
var orderMap = requestedDistricts
.Select((district, index) => new { district.DistrictCode, Index = index })
.ToDictionary(item => item.DistrictCode, item => item.Index, StringComparer.OrdinalIgnoreCase);
var districtMap = requestedDistricts.ToDictionary(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase);
var overviewItems = new List<(int Order, CountingOverviewItem Item)>();
foreach (var districtChunk in requestedDistricts.Chunk(24))
{
var ids = string.Join(",", districtChunk.Select(district => district.DistrictCode));
var items = await GetArrayAsync<SbsCountingItem>(
$"gaepyo/{configuration.SungerType}/sungergus?ids={ids}",
cancellationToken).ConfigureAwait(false);
foreach (var item in items)
{
var regionId = item.Region?.Id;
if (string.IsNullOrWhiteSpace(regionId) ||
!districtMap.TryGetValue(regionId, out var districtOption) ||
!orderMap.TryGetValue(regionId, out var order))
{
continue;
}
var totalVotes = Math.Max(0, item.Total?.Tupyosu ?? 0);
var countedVotes = Math.Max(0, item.Total?.Gaepyosu ?? 0);
var uncountedVotes = item.Total?.UncountedPyosu ?? Math.Max(0, totalVotes - countedVotes);
var countedRate = item.Total?.GaepyoRate ?? (totalVotes <= 0 ? 0 : countedVotes * 100d / totalVotes);
overviewItems.Add((order, new CountingOverviewItem(
DisplayName: districtOption.DisplayName,
CountedRate: Math.Round(countedRate, 1, MidpointRounding.AwayFromZero),
CountedVotes: countedVotes,
TotalVotes: totalVotes,
UncountedVotes: Math.Max(0, uncountedVotes))));
}
}
return overviewItems
.OrderBy(item => item.Order)
.Select(item => item.Item)
.ToArray();
}
public void Dispose()
{
if (_disposeHttpClient)
{
_httpClient.Dispose();
}
}
private async Task<SbsElectionRefreshResult> RefreshTurnoutAsync(
SbsElectionConfiguration configuration,
string districtName,
string districtCode,
CancellationToken cancellationToken)
{
if (!configuration.SupportsPreElection)
{
throw new InvalidOperationException(
"선택한 선거 종류는 SBS API 문서 기준으로 사전 투표율 연동 대상이 아닙니다.");
}
var sido = await ResolveSidoRegionAsync(districtName, districtCode, cancellationToken).ConfigureAwait(false);
var items = await GetArrayAsync<SbsTurnoutItem>(
$"tupyo/{configuration.SungerType}/sidos?ids={Uri.EscapeDataString(sido.Id)}",
cancellationToken).ConfigureAwait(false);
var item = items.FirstOrDefault()
?? throw new InvalidOperationException("SBS API가 해당 지역의 투표 데이터를 반환하지 않았습니다.");
var regionName = ExpandRegionName(item.Region?.Name1 ?? item.Region?.Name ?? districtName);
return new SbsElectionRefreshResult(
DistrictName: regionName,
DistrictCode: sido.Id,
RegionName: regionName,
ElectionDistrictName: regionName,
TotalExpectedVotes: item.Sungerinsu,
TurnoutVotes: item.Total?.Tupyosu ?? 0,
CountedRate: null,
CountedVotes: null,
RemainingVotes: null,
Candidates: null,
ReceivedAt: DateTimeOffset.Now,
SourcePath: $"GET /tupyo/{configuration.SungerType}/sidos?ids={sido.Id}");
}
private async Task<SbsElectionRefreshResult> RefreshCountingAsync(
SbsElectionConfiguration configuration,
string districtName,
string districtCode,
CancellationToken cancellationToken)
{
var district = await ResolveElectionDistrictAsync(
configuration.SungerType,
districtName,
districtCode,
cancellationToken).ConfigureAwait(false);
var items = await GetArrayAsync<SbsCountingItem>(
$"gaepyo/{configuration.SungerType}/sungergus?ids={Uri.EscapeDataString(district.Id)}",
cancellationToken).ConfigureAwait(false);
var item = items.FirstOrDefault()
?? throw new InvalidOperationException("SBS API가 해당 지역의 개표 데이터를 반환하지 않았습니다.");
var candidates = (item.Hubojas ?? [])
.Select(MapCandidate)
.OrderByDescending(candidate => candidate.VoteCount)
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
.ToArray();
if (candidates.Length == 0)
{
throw new InvalidOperationException("SBS API 응답에 후보자 정보가 없습니다.");
}
var totalVotes = item.Total?.Tupyosu ?? candidates.Sum(candidate => candidate.VoteCount);
var regionName = ExpandRegionName(item.Region?.Name1 ?? district.Name1 ?? districtName);
var districtLabel = BuildElectionDistrictLabel(configuration.SungerType, regionName, item.Region, district);
var displayName = configuration.SungerType == 4
? BuildFullDistrictDisplayName(regionName, districtLabel)
: regionName;
return new SbsElectionRefreshResult(
DistrictName: displayName,
DistrictCode: district.Id,
RegionName: regionName,
ElectionDistrictName: districtLabel,
TotalExpectedVotes: Math.Max(totalVotes, 1),
TurnoutVotes: Math.Max(totalVotes, 0),
CountedRate: item.Total?.GaepyoRate,
CountedVotes: item.Total?.Gaepyosu,
RemainingVotes: item.Total?.UncountedPyosu,
Candidates: candidates,
ReceivedAt: DateTimeOffset.Now,
SourcePath: $"GET /gaepyo/{configuration.SungerType}/sungergus?ids={district.Id}");
}
private static CandidateEntry MapCandidate(SbsCandidateItem item)
{
var total = item.Total ?? new SbsCandidateVoteSnapshot();
return new CandidateEntry
{
CandidateCode = string.IsNullOrWhiteSpace(item.Giho) ? (item.Name ?? "후보") : item.Giho,
Name = item.Name ?? "후보자명 미상",
Party = item.Jeongdang?.Name ?? "무소속",
VoteCount = total.Dugpyosu,
VoteRate = total.DugpyoRate,
HasImage = true,
ManualJudgement = MapJudgement(item.Degree)
};
}
private static CandidateJudgement MapJudgement(string? degree)
{
return degree switch
{
"40" => CandidateJudgement.Leading,
"50" => CandidateJudgement.Confirmed,
"60" => CandidateJudgement.ElectedInProgress,
"80" => CandidateJudgement.UnopposedElected,
"90" => CandidateJudgement.ElectedAfterCountComplete,
_ => CandidateJudgement.None
};
}
private async Task<SbsRegionInfo> ResolveSidoRegionAsync(
string districtName,
string districtCode,
CancellationToken cancellationToken)
{
_sidoRegions ??= await GetValueAsync<SbsRegionInfo>("sungerInfo/region?type=시도", cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(districtCode))
{
var matchedById = _sidoRegions.FirstOrDefault(region => string.Equals(region.Id, districtCode, StringComparison.OrdinalIgnoreCase));
if (matchedById is not null)
{
return matchedById;
}
}
var normalizedName = NormalizeRegionName(districtName);
var matchedByName = _sidoRegions.FirstOrDefault(region =>
string.Equals(NormalizeRegionName(region.Name), normalizedName, StringComparison.Ordinal) ||
string.Equals(NormalizeRegionName(region.Name1), normalizedName, StringComparison.Ordinal));
return matchedByName
?? throw new InvalidOperationException($"시도 정보를 찾지 못했습니다: '{districtName}'");
}
private async Task<SbsRegionInfo> ResolveElectionDistrictAsync(
int sungerType,
string districtName,
string districtCode,
CancellationToken cancellationToken)
{
var regions = await GetElectionDistrictRegionsAsync(sungerType, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(districtCode))
{
var matchedById = regions.FirstOrDefault(region => string.Equals(region.Id, districtCode, StringComparison.OrdinalIgnoreCase));
if (matchedById is not null)
{
return matchedById;
}
}
var normalizedName = NormalizeRegionName(districtName);
if (!string.IsNullOrWhiteSpace(normalizedName))
{
var matchedByName = regions
.Where(region => MatchesElectionDistrictName(region, normalizedName))
.ToArray();
if (matchedByName.Length == 1)
{
return matchedByName[0];
}
if (matchedByName.Length > 1 && !string.IsNullOrWhiteSpace(districtCode))
{
var narrowed = matchedByName
.Where(region =>
string.Equals(region.Name1Id, districtCode, StringComparison.OrdinalIgnoreCase) ||
string.Equals(region.Name2Id, districtCode, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (narrowed.Length == 1)
{
return narrowed[0];
}
}
}
if (!string.IsNullOrWhiteSpace(districtCode))
{
var matchedBySecondaryCode = regions
.Where(region => string.Equals(region.Name2Id, districtCode, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (matchedBySecondaryCode.Length == 1)
{
return matchedBySecondaryCode[0];
}
}
throw new InvalidOperationException($"선거구 정보를 찾지 못했습니다: '{districtName}'");
}
private async Task<IReadOnlyList<SbsRegionInfo>> GetElectionDistrictRegionsAsync(
int sungerType,
CancellationToken cancellationToken)
{
if (!_districtRegions.TryGetValue(sungerType, out var regions))
{
regions = await GetValueAsync<SbsRegionInfo>(
$"sungerInfo/region?type=선거구&sungerType={sungerType}",
cancellationToken).ConfigureAwait(false);
_districtRegions[sungerType] = regions;
}
return regions;
}
private async Task<IReadOnlyList<T>> GetValueAsync<T>(string relativePath, CancellationToken cancellationToken)
{
var json = await GetJsonAsync(relativePath, cancellationToken).ConfigureAwait(false);
return DeserializeList<T>(json, relativePath, preferValueProperty: true);
}
private async Task<IReadOnlyList<T>> GetArrayAsync<T>(string relativePath, CancellationToken cancellationToken)
{
var json = await GetJsonAsync(relativePath, cancellationToken).ConfigureAwait(false);
return DeserializeList<T>(json, relativePath, preferValueProperty: false);
}
private async Task<string> GetJsonAsync(string relativePath, CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(relativePath, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
return Encoding.UTF8.GetString(bytes);
}
private static IReadOnlyList<T> DeserializeList<T>(string json, string relativePath, bool preferValueProperty)
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
JsonElement arrayElement;
if (preferValueProperty &&
root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("value", out var valueElement) &&
valueElement.ValueKind == JsonValueKind.Array)
{
arrayElement = valueElement;
}
else if (root.ValueKind == JsonValueKind.Array)
{
arrayElement = root;
}
else if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("value", out valueElement) &&
valueElement.ValueKind == JsonValueKind.Array)
{
arrayElement = valueElement;
}
else
{
throw new InvalidOperationException($"SBS API 배열 응답을 확인하지 못했습니다: {relativePath}");
}
var result = JsonSerializer.Deserialize<List<T>>(arrayElement.GetRawText(), SerializerOptions);
return result ?? [];
}
private static bool MatchesElectionDistrictName(SbsRegionInfo region, string normalizedName)
{
if (string.IsNullOrWhiteSpace(normalizedName))
{
return false;
}
return string.Equals(NormalizeRegionName(region.Name), normalizedName, StringComparison.Ordinal) ||
string.Equals(NormalizeRegionName(region.Name1), normalizedName, StringComparison.Ordinal) ||
string.Equals(NormalizeRegionName(region.Name2), normalizedName, StringComparison.Ordinal) ||
string.Equals(NormalizeRegionName(region.Name4), normalizedName, StringComparison.Ordinal) ||
string.Equals(NormalizeRegionName(BuildElectionDistrictLabel(region)), normalizedName, StringComparison.Ordinal) ||
string.Equals(NormalizeRegionName(BuildFullDistrictDisplayName(region)), normalizedName, StringComparison.Ordinal);
}
private static string BuildElectionDistrictLabel(int sungerType, string regionName, SbsTurnoutRegion? region, SbsRegionInfo fallback)
{
return sungerType switch
{
3 => BuildMayorGovernorLabel(regionName, region?.Name4 ?? fallback.Name4),
4 => BuildElectionDistrictLabel(region, fallback),
_ => regionName
};
}
private static string BuildElectionDistrictLabel(SbsRegionInfo region)
{
return BuildElectionDistrictLabel(region.Name4, region.Name2 ?? region.Name);
}
private static string BuildElectionDistrictLabel(SbsTurnoutRegion? region, SbsRegionInfo fallback)
{
if (region is not null)
{
var resolved = BuildElectionDistrictLabel(region.Name4, region.Name2 ?? region.Name);
if (!string.IsNullOrWhiteSpace(resolved))
{
return resolved;
}
}
return BuildElectionDistrictLabel(fallback);
}
private static string BuildElectionDistrictLabel(string? officeName, string? shortName)
{
if (!string.IsNullOrWhiteSpace(officeName))
{
var trimmed = officeName.Trim();
if (trimmed.EndsWith("구청장", StringComparison.Ordinal))
{
return trimmed[..^2];
}
if (trimmed.EndsWith("시장", StringComparison.Ordinal))
{
return trimmed[..^1];
}
if (trimmed.EndsWith("군수", StringComparison.Ordinal))
{
return trimmed[..^1];
}
return trimmed;
}
return shortName?.Trim() ?? string.Empty;
}
private static string BuildMayorGovernorLabel(string regionName, string? officeName)
{
var normalizedRegionName = ExpandRegionName(regionName);
if (!string.IsNullOrWhiteSpace(officeName))
{
var trimmedOfficeName = officeName.Trim();
if (trimmedOfficeName.EndsWith("시장", StringComparison.Ordinal))
{
return $"{NormalizeRegionName(normalizedRegionName)}시장";
}
if (trimmedOfficeName.EndsWith("지사", StringComparison.Ordinal))
{
return $"{normalizedRegionName}지사";
}
return trimmedOfficeName;
}
if (normalizedRegionName.EndsWith("시", StringComparison.Ordinal))
{
return $"{NormalizeRegionName(normalizedRegionName)}시장";
}
if (normalizedRegionName.EndsWith("도", StringComparison.Ordinal))
{
return $"{normalizedRegionName}지사";
}
return normalizedRegionName;
}
private static string BuildFullDistrictDisplayName(SbsRegionInfo region)
{
return BuildFullDistrictDisplayName(
ExpandRegionName(region.Name1 ?? region.Name),
BuildElectionDistrictLabel(region));
}
private static string BuildFullDistrictDisplayName(string regionName, string districtLabel)
{
if (string.IsNullOrWhiteSpace(regionName))
{
return districtLabel;
}
if (string.IsNullOrWhiteSpace(districtLabel) || string.Equals(regionName, districtLabel, StringComparison.Ordinal))
{
return regionName;
}
return $"{regionName} {districtLabel}";
}
private static DistrictSelectionOption CreateDistrictSelectionOption(int sungerType, SbsRegionInfo region)
{
var regionName = ExpandRegionName(region.Name1 ?? region.Name);
var districtName = sungerType switch
{
3 => BuildMayorGovernorLabel(regionName, region.Name4),
4 => BuildElectionDistrictLabel(region),
_ => regionName
};
var displayName = sungerType == 4
? BuildFullDistrictDisplayName(regionName, districtName)
: regionName;
return new DistrictSelectionOption(
DisplayName: displayName,
DistrictCode: region.Id,
RegionName: regionName,
DistrictName: districtName,
ParentRegionCode: region.Name1Id ?? string.Empty);
}
private static string NormalizeRegionName(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Trim();
foreach (var pair in FullRegionNames)
{
normalized = normalized.Replace(pair.Value, pair.Key, StringComparison.Ordinal);
}
return normalized
.Replace("특별자치도", string.Empty, StringComparison.Ordinal)
.Replace("특별자치시", string.Empty, StringComparison.Ordinal)
.Replace("특별시", string.Empty, StringComparison.Ordinal)
.Replace("광역시", string.Empty, StringComparison.Ordinal)
.Replace("구청장", "구", StringComparison.Ordinal)
.Replace("시장", "시", StringComparison.Ordinal)
.Replace("군수", "군", StringComparison.Ordinal)
.Replace("지사", string.Empty, StringComparison.Ordinal)
.Replace("교육감", string.Empty, StringComparison.Ordinal)
.Replace(" ", string.Empty, StringComparison.Ordinal)
.Trim();
}
private static string ExpandRegionName(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = NormalizeRegionName(value);
return FullRegionNames.TryGetValue(normalized, out var fullName)
? fullName
: value.Trim();
}
private readonly record struct SbsElectionConfiguration(int SungerType, bool SupportsPreElection);
public sealed record DistrictSelectionOption(
string DisplayName,
string DistrictCode,
string RegionName,
string DistrictName,
string ParentRegionCode);
public sealed record SbsElectionRefreshResult(
string DistrictName,
string DistrictCode,
string RegionName,
string ElectionDistrictName,
int TotalExpectedVotes,
int TurnoutVotes,
double? CountedRate,
int? CountedVotes,
int? RemainingVotes,
IReadOnlyList<CandidateEntry>? Candidates,
DateTimeOffset ReceivedAt,
string SourcePath);
public sealed record CountingOverviewItem(
string DisplayName,
double CountedRate,
int CountedVotes,
int TotalVotes,
int UncountedVotes);
private sealed class SbsRegionInfo
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("name1")]
public string? Name1 { get; set; }
[JsonPropertyName("name2")]
public string? Name2 { get; set; }
[JsonPropertyName("name4")]
public string? Name4 { get; set; }
[JsonPropertyName("name1Id")]
public string? Name1Id { get; set; }
[JsonPropertyName("name2Id")]
public string? Name2Id { get; set; }
}
private sealed class SbsTurnoutItem
{
[JsonPropertyName("region")]
public SbsTurnoutRegion? Region { get; set; }
[JsonPropertyName("sungerinsu")]
public int Sungerinsu { get; set; }
[JsonPropertyName("total")]
public SbsTurnoutVoteSnapshot? Total { get; set; }
}
private sealed class SbsTurnoutRegion
{
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("name1")]
public string? Name1 { get; set; }
[JsonPropertyName("name2")]
public string? Name2 { get; set; }
[JsonPropertyName("name4")]
public string? Name4 { get; set; }
[JsonPropertyName("name1Id")]
public string? Name1Id { get; set; }
[JsonPropertyName("name2Id")]
public string? Name2Id { get; set; }
[JsonPropertyName("name4Id")]
public string? Name4Id { get; set; }
}
private sealed class SbsTurnoutVoteSnapshot
{
[JsonPropertyName("tupyosu")]
public int Tupyosu { get; set; }
}
private sealed class SbsCountingItem
{
[JsonPropertyName("region")]
public SbsTurnoutRegion? Region { get; set; }
[JsonPropertyName("total")]
public SbsCountingVoteSnapshot? Total { get; set; }
[JsonPropertyName("hubojas")]
public List<SbsCandidateItem>? Hubojas { get; set; }
}
private sealed class SbsCountingVoteSnapshot
{
[JsonPropertyName("tupyosu")]
public int Tupyosu { get; set; }
[JsonPropertyName("gaepyosu")]
public int Gaepyosu { get; set; }
[JsonPropertyName("gaepyoRate")]
public double GaepyoRate { get; set; }
[JsonPropertyName("uncountedPyosu")]
public int UncountedPyosu { get; set; }
}
private sealed class SbsCandidateItem
{
[JsonPropertyName("giho")]
public string? Giho { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("degree")]
public string? Degree { get; set; }
[JsonPropertyName("jeongdang")]
public SbsPartyInfo? Jeongdang { get; set; }
[JsonPropertyName("total")]
public SbsCandidateVoteSnapshot? Total { get; set; }
}
private sealed class SbsPartyInfo
{
[JsonPropertyName("name")]
public string? Name { get; set; }
}
private sealed class SbsCandidateVoteSnapshot
{
[JsonPropertyName("rank")]
public int Rank { get; set; }
[JsonPropertyName("dugpyosu")]
public int Dugpyosu { get; set; }
[JsonPropertyName("dugpyoRate")]
public double DugpyoRate { get; set; }
}
}