Files
Tornado3_2026Election/Tornado3_2026Election/Services/PreElectionHistoryService.cs
2026-05-13 11:21:48 +09:00

676 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public sealed class PreElectionHistoryService
{
private const string RelativeAssetPath = @"Assets\Data\pre_election_history.json";
private static readonly string[] SupportedElectionTypes =
[
"광역단체장",
"교육감",
"기초단체장"
];
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip
};
private static readonly IReadOnlyDictionary<string, string> RegionAliases =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["서울"] = "서울",
["서울특별시"] = "서울",
["부산"] = "부산",
["부산광역시"] = "부산",
["대구"] = "대구",
["대구광역시"] = "대구",
["인천"] = "인천",
["인천광역시"] = "인천",
["광주"] = "광주",
["광주광역시"] = "광주",
["대전"] = "대전",
["대전광역시"] = "대전",
["울산"] = "울산",
["울산광역시"] = "울산",
["세종"] = "세종",
["세종특별자치시"] = "세종",
["경기"] = "경기",
["경기도"] = "경기",
["강원"] = "강원",
["강원도"] = "강원",
["강원특별자치도"] = "강원",
["충북"] = "충북",
["충청북도"] = "충북",
["충남"] = "충남",
["충청남도"] = "충남",
["전북"] = "전북",
["전라북도"] = "전북",
["전북특별자치도"] = "전북",
["전남"] = "전남",
["전라남도"] = "전남",
["경북"] = "경북",
["경상북도"] = "경북",
["경남"] = "경남",
["경상남도"] = "경남",
["제주"] = "제주",
["제주도"] = "제주",
["제주특별자치도"] = "제주"
};
private static readonly string[] RegionLabels = RegionAliases.Keys
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderByDescending(value => value.Length)
.ToArray();
private readonly LogService _logService;
private readonly string? _assetPath;
private PreElectionHistoryCatalog _catalog;
private IReadOnlyDictionary<string, IReadOnlyList<PreElectionHistoryRecord>> _recordsByElectionType;
private IReadOnlyDictionary<string, IReadOnlyDictionary<string, PreElectionHistoryRecord>> _recordsByLookupKey;
public PreElectionHistoryService(LogService logService)
{
_logService = logService;
_assetPath = ResolveAssetPath();
_catalog = NormalizeCatalog(LoadCatalog(_assetPath));
_recordsByElectionType = new Dictionary<string, IReadOnlyList<PreElectionHistoryRecord>>(StringComparer.Ordinal);
_recordsByLookupKey = new Dictionary<string, IReadOnlyDictionary<string, PreElectionHistoryRecord>>(StringComparer.Ordinal);
RebuildIndexes();
}
public void SaveRecord(PreElectionHistoryRecord record)
{
if (record is null)
{
throw new ArgumentNullException(nameof(record));
}
var normalizedRecord = NormalizeRecord(record);
var canonicalElectionType = normalizedRecord.ElectionType;
var records = _catalog.Records.ToList();
var existingIndex = records.FindIndex(existingRecord =>
string.Equals(existingRecord.ElectionType, canonicalElectionType, StringComparison.Ordinal) &&
string.Equals(existingRecord.Key, normalizedRecord.Key, StringComparison.OrdinalIgnoreCase));
if (existingIndex >= 0)
{
records[existingIndex] = normalizedRecord;
}
else
{
records.Add(normalizedRecord);
}
_catalog = new PreElectionHistoryCatalog
{
Metadata = new PreElectionHistoryMetadata
{
Version = _catalog.Metadata.Version,
GeneratedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
CoverageNotes = _catalog.Metadata.CoverageNotes.ToArray(),
Sources = _catalog.Metadata.Sources.ToArray()
},
Records = records
.OrderBy(existingRecord => Array.IndexOf(SupportedElectionTypes, NormalizeElectionType(existingRecord.ElectionType)))
.ThenBy(existingRecord => existingRecord.DisplayName, StringComparer.Ordinal)
.ToArray()
};
_catalog = NormalizeCatalog(_catalog);
PersistCatalog();
RebuildIndexes();
}
private static PreElectionHistoryCatalog NormalizeCatalog(PreElectionHistoryCatalog catalog)
{
var records = catalog.Records
.Select(NormalizeRecord)
.GroupBy(record => $"{record.ElectionType}|{record.Key}", StringComparer.OrdinalIgnoreCase)
.Select(MergeRecordGroup)
.OrderBy(record => Array.IndexOf(SupportedElectionTypes, record.ElectionType))
.ThenBy(record => record.DisplayName, StringComparer.Ordinal)
.ToArray();
return new PreElectionHistoryCatalog
{
Metadata = catalog.Metadata,
Records = records
};
}
private static PreElectionHistoryRecord NormalizeRecord(PreElectionHistoryRecord record)
{
var canonicalElectionType = NormalizeElectionType(record.ElectionType);
var regionName = record.RegionName ?? string.Empty;
var regionKey = string.IsNullOrWhiteSpace(record.RegionKey)
? NormalizeRegionKey(regionName)
: record.RegionKey.Trim();
var districtName = record.DistrictName ?? string.Empty;
var displayName = record.DisplayName ?? string.Empty;
if (string.Equals(canonicalElectionType, "기초단체장", StringComparison.Ordinal))
{
regionKey = string.IsNullOrWhiteSpace(regionKey)
? ResolveRegionKey(regionName, districtName, [displayName, record.Key])
: regionKey;
districtName = NormalizeBasicDistrictDisplayName(districtName, displayName, regionName);
var districtKey = NormalizeBasicDistrictToken(districtName);
var recordKey = BuildBasicLookupKey(regionKey, districtKey);
displayName = string.IsNullOrWhiteSpace(regionName) || string.IsNullOrWhiteSpace(districtName)
? displayName.Trim()
: $"{regionName.Trim()} {districtName}";
return new PreElectionHistoryRecord
{
ElectionType = canonicalElectionType,
Key = string.IsNullOrWhiteSpace(recordKey) ? record.Key ?? string.Empty : recordKey,
RegionKey = regionKey,
RegionName = regionName,
DistrictName = districtName,
DisplayName = displayName,
TurnoutHistory = SortTurnoutHistory(record.TurnoutHistory),
WinnerHistory = SortWinnerHistory(record.WinnerHistory)
};
}
return new PreElectionHistoryRecord
{
ElectionType = canonicalElectionType,
Key = record.Key ?? string.Empty,
RegionKey = regionKey,
RegionName = regionName,
DistrictName = districtName,
DisplayName = displayName,
TurnoutHistory = SortTurnoutHistory(record.TurnoutHistory),
WinnerHistory = SortWinnerHistory(record.WinnerHistory)
};
}
private static PreElectionHistoryRecord MergeRecordGroup(IGrouping<string, PreElectionHistoryRecord> records)
{
var primary = records
.OrderBy(record => record.DisplayName.Contains('(') ? 1 : 0)
.ThenByDescending(record => record.WinnerHistory.Count + record.TurnoutHistory.Count)
.First();
return new PreElectionHistoryRecord
{
ElectionType = primary.ElectionType,
Key = primary.Key,
RegionKey = primary.RegionKey,
RegionName = primary.RegionName,
DistrictName = primary.DistrictName,
DisplayName = primary.DisplayName,
TurnoutHistory = records
.SelectMany(record => record.TurnoutHistory)
.GroupBy(entry => entry.ElectionOrder)
.Select(group => group.First())
.OrderBy(entry => entry.ElectionOrder)
.ToArray(),
WinnerHistory = records
.SelectMany(record => record.WinnerHistory)
.GroupBy(entry => entry.ElectionOrder)
.Select(group => group.First())
.OrderBy(entry => entry.ElectionOrder)
.ToArray()
};
}
private static IReadOnlyList<PreElectionHistoricalTurnoutEntry> SortTurnoutHistory(
IReadOnlyList<PreElectionHistoricalTurnoutEntry>? entries)
{
return (entries ?? Array.Empty<PreElectionHistoricalTurnoutEntry>())
.OrderBy(entry => entry.ElectionOrder)
.ToArray();
}
private static IReadOnlyList<PreElectionHistoricalWinnerEntry> SortWinnerHistory(
IReadOnlyList<PreElectionHistoricalWinnerEntry>? entries)
{
return (entries ?? Array.Empty<PreElectionHistoricalWinnerEntry>())
.OrderBy(entry => entry.ElectionOrder)
.ToArray();
}
private void RebuildIndexes()
{
_recordsByElectionType = SupportedElectionTypes.ToDictionary(
electionType => electionType,
electionType => (IReadOnlyList<PreElectionHistoryRecord>)_catalog.Records
.Where(record => string.Equals(record.ElectionType, electionType, StringComparison.Ordinal))
.OrderBy(record => record.DisplayName, StringComparer.Ordinal)
.ToArray(),
StringComparer.Ordinal);
_recordsByLookupKey = SupportedElectionTypes.ToDictionary(
electionType => electionType,
electionType => BuildLookupIndex(_recordsByElectionType[electionType], electionType),
StringComparer.Ordinal);
}
public string GeneratedAt => _catalog.Metadata.GeneratedAt;
public IReadOnlyList<string> CoverageNotes => _catalog.Metadata.CoverageNotes;
public IReadOnlyList<PreElectionHistorySourceReference> Sources => _catalog.Metadata.Sources;
public IReadOnlyList<string> GetSupportedElectionTypes() => SupportedElectionTypes;
public int GetRecordCount(string electionType)
{
return _recordsByElectionType.TryGetValue(NormalizeElectionType(electionType), out var records)
? records.Count
: 0;
}
public IReadOnlyList<PreElectionHistoryRecord> GetSelectionRecords(string electionType)
{
return _recordsByElectionType.TryGetValue(NormalizeElectionType(electionType), out var records)
? records
: Array.Empty<PreElectionHistoryRecord>();
}
public string GetCoverageSummary(string electionType)
{
var canonicalElectionType = NormalizeElectionType(electionType);
var recordCount = GetRecordCount(canonicalElectionType);
if (recordCount == 0)
{
return "저장형 사전 데이터가 아직 없습니다.";
}
return canonicalElectionType switch
{
"광역단체장" => $"{GeneratedAt} 저장 / 전국 {recordCount}개 시도 / 투표율 2002~2022 / 당선자 1995~2022",
"교육감" => $"{GeneratedAt} 저장 / 전국 {recordCount}개 시도 / 직선제 기준 2010~2022",
"기초단체장" => $"{GeneratedAt} 저장 / 전국 {recordCount}개 선거구 / 공식 API 기준 2002~2022",
_ => $"{GeneratedAt} 저장 / {recordCount}건"
};
}
public string GetCoverageDetail(string electionType)
{
var canonicalElectionType = NormalizeElectionType(electionType);
return canonicalElectionType switch
{
"광역단체장" => "광역단체장 당선자는 1회부터 8회까지, 투표율은 3회부터 8회까지 저장했습니다.",
"교육감" => "교육감은 전국 직선제가 적용된 2010년부터 2022년까지 저장했습니다.",
"기초단체장" => "기초단체장은 중앙선거관리위원회 OpenAPI 기준 2002년부터 2022년까지 저장했습니다. 구 단위로 분할된 시 지역은 동일 시 단위로 투표율을 합산했습니다.",
_ => "저장형 사전 데이터 설명이 없습니다."
};
}
public PreElectionHistoryRecord? ResolveHistory(string electionType, string? regionName, string? districtName)
{
var canonicalElectionType = NormalizeElectionType(electionType);
if (!_recordsByLookupKey.TryGetValue(canonicalElectionType, out var records))
{
return null;
}
foreach (var key in BuildLookupKeys(canonicalElectionType, regionName, districtName))
{
if (records.TryGetValue(key, out var record))
{
return record;
}
}
if (string.Equals(canonicalElectionType, SupportedElectionTypes[2], StringComparison.Ordinal))
{
return ResolveUniqueBasicDistrictHistory(canonicalElectionType, regionName, districtName);
}
return null;
}
private PreElectionHistoryRecord? ResolveUniqueBasicDistrictHistory(string electionType, string? regionName, string? districtName)
{
if (!_recordsByElectionType.TryGetValue(electionType, out var records))
{
return null;
}
var districtTokens = new[] { districtName, regionName }
.Select(NormalizeBasicDistrictToken)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (districtTokens.Length == 0)
{
return null;
}
var matches = records
.Where(record => districtTokens.Contains(NormalizeBasicDistrictToken(record.DistrictName), StringComparer.OrdinalIgnoreCase) ||
districtTokens.Contains(NormalizeBasicDistrictToken(record.DisplayName), StringComparer.OrdinalIgnoreCase))
.GroupBy(record => record.Key, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.Take(2)
.ToArray();
return matches.Length == 1 ? matches[0] : null;
}
public static string NormalizeElectionType(string? electionType)
{
return electionType switch
{
"교육감" => "교육감",
"기초단체장" => "기초단체장",
_ => "광역단체장"
};
}
public static string NormalizeRegionKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return NormalizeRegionKeys(value).FirstOrDefault() ?? string.Empty;
}
private static IReadOnlyDictionary<string, PreElectionHistoryRecord> BuildLookupIndex(
IReadOnlyList<PreElectionHistoryRecord> records,
string electionType)
{
var index = new Dictionary<string, PreElectionHistoryRecord>(StringComparer.OrdinalIgnoreCase);
foreach (var record in records)
{
foreach (var key in BuildLookupKeys(
electionType,
record.RegionName,
record.DistrictName,
record.DisplayName,
record.RegionKey,
record.Key))
{
if (!index.ContainsKey(key))
{
index[key] = record;
}
}
}
return index;
}
private static IEnumerable<string> BuildLookupKeys(
string electionType,
string? regionName,
string? districtName,
params string?[] additionalValues)
{
electionType = NormalizeElectionType(electionType);
var yielded = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (electionType == "기초단체장")
{
foreach (var additionalValue in additionalValues.Where(value => !string.IsNullOrWhiteSpace(value)))
{
var trimmed = additionalValue!.Trim();
if (trimmed.Contains('|') && yielded.Add(trimmed))
{
yield return trimmed;
}
}
var regionKeys = ResolveRegionKeys(regionName, districtName, additionalValues);
var districtKeys = new[]
{
NormalizeBasicDistrictToken(districtName),
NormalizeBasicDistrictToken(regionName)
}
.Concat(additionalValues
.Where(value => !string.IsNullOrWhiteSpace(value) && !value!.Contains('|'))
.Select(NormalizeBasicDistrictToken))
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var regionKey in regionKeys)
{
foreach (var districtKey in districtKeys)
{
var combined = BuildBasicLookupKey(regionKey, districtKey);
if (!string.IsNullOrWhiteSpace(combined) && yielded.Add(combined))
{
yield return combined;
}
}
}
yield break;
}
var regionCandidates = new[]
{
NormalizeRegionKey(regionName),
NormalizeRegionKey(districtName)
}
.Concat(additionalValues.Select(NormalizeRegionKey))
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var candidate in regionCandidates)
{
if (yielded.Add(candidate))
{
yield return candidate;
}
}
}
private static string ResolveRegionKey(string? regionName, string? districtName, IEnumerable<string?> additionalValues)
{
var candidates = new[] { regionName, districtName }.Concat(additionalValues);
foreach (var candidate in candidates)
{
var normalized = NormalizeRegionKey(candidate);
if (!string.IsNullOrWhiteSpace(normalized))
{
return normalized;
}
}
return string.Empty;
}
private static IReadOnlyList<string> ResolveRegionKeys(string? regionName, string? districtName, IEnumerable<string?> additionalValues)
{
return new[] { regionName, districtName }
.Concat(additionalValues)
.SelectMany(NormalizeRegionKeys)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static IEnumerable<string> NormalizeRegionKeys(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
yield break;
}
var trimmed = value.Trim();
var yielded = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var regionLabel in RegionLabels)
{
if (trimmed.Contains(regionLabel, StringComparison.OrdinalIgnoreCase) &&
RegionAliases.TryGetValue(regionLabel, out var normalized) &&
yielded.Add(normalized))
{
yield return normalized;
}
}
if (RegionAliases.TryGetValue(trimmed, out var exactMatch) && yielded.Add(exactMatch))
{
yield return exactMatch;
}
}
private static string BuildBasicLookupKey(string regionKey, string districtKey)
{
if (string.IsNullOrWhiteSpace(regionKey) || string.IsNullOrWhiteSpace(districtKey))
{
return string.Empty;
}
return $"{regionKey}|{districtKey}";
}
private static string NormalizeBasicDistrictToken(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = StripBasicDistrictDisambiguation(value.Trim());
foreach (var regionLabel in RegionLabels)
{
normalized = RemoveLeadingRegionLabel(normalized, regionLabel);
}
normalized = normalized
.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();
return normalized;
}
private static string RemoveLeadingRegionLabel(string value, string regionLabel)
{
if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(regionLabel))
{
return value;
}
var trimmed = value.TrimStart();
if (!trimmed.StartsWith(regionLabel, StringComparison.OrdinalIgnoreCase))
{
return value;
}
return trimmed[regionLabel.Length..].TrimStart();
}
private static string NormalizeBasicDistrictDisplayName(
string? districtName,
string? displayName,
string? regionName)
{
var normalized = string.IsNullOrWhiteSpace(districtName)
? displayName?.Trim() ?? string.Empty
: districtName.Trim();
if (!string.IsNullOrWhiteSpace(regionName) &&
normalized.StartsWith(regionName.Trim(), StringComparison.Ordinal))
{
normalized = normalized[regionName.Trim().Length..].Trim();
}
return StripBasicDistrictDisambiguation(normalized);
}
private static string StripBasicDistrictDisambiguation(string value)
{
var normalized = value.Trim();
foreach (var regionLabel in RegionLabels)
{
normalized = normalized.Replace($"({regionLabel})", string.Empty, StringComparison.OrdinalIgnoreCase);
}
return normalized
.Replace("()", string.Empty, StringComparison.Ordinal)
.Trim();
}
private void PersistCatalog()
{
if (string.IsNullOrWhiteSpace(_assetPath))
{
throw new InvalidOperationException("사전 데이터 저장 경로를 찾지 못했습니다.");
}
var directoryPath = Path.GetDirectoryName(_assetPath);
if (!string.IsNullOrWhiteSpace(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}
var saveOptions = new JsonSerializerOptions(SerializerOptions)
{
WriteIndented = true
};
var json = JsonSerializer.Serialize(_catalog, saveOptions);
File.WriteAllText(_assetPath, json, new UTF8Encoding(false));
}
private PreElectionHistoryCatalog LoadCatalog(string? assetPath)
{
try
{
if (string.IsNullOrWhiteSpace(assetPath) || !File.Exists(assetPath))
{
_logService.Warning($"사전 데이터 파일을 찾지 못했습니다: {RelativeAssetPath}");
return new PreElectionHistoryCatalog();
}
var json = File.ReadAllText(assetPath);
return JsonSerializer.Deserialize<PreElectionHistoryCatalog>(json, SerializerOptions)
?? new PreElectionHistoryCatalog();
}
catch (Exception ex)
{
_logService.Warning($"사전 데이터 로드에 실패했습니다: {ex.Message}");
return new PreElectionHistoryCatalog();
}
}
private static string? ResolveAssetPath()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "Tornado3_2026Election.csproj")))
{
return Path.Combine(current.FullName, RelativeAssetPath);
}
var repositoryProjectPath = Path.Combine(
current.FullName,
"Tornado3_2026Election",
"Tornado3_2026Election.csproj");
if (File.Exists(repositoryProjectPath))
{
return Path.Combine(current.FullName, "Tornado3_2026Election", RelativeAssetPath);
}
current = current.Parent;
}
var bundledPath = Path.Combine(AppContext.BaseDirectory, RelativeAssetPath);
return File.Exists(bundledPath) ? bundledPath : null;
}
}