790 lines
29 KiB
C#
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; }
|
|
}
|
|
}
|