676 lines
25 KiB
C#
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;
|
|
}
|
|
}
|