853 lines
26 KiB
C#
853 lines
26 KiB
C#
using System.Globalization;
|
||
using System.Text.RegularExpressions;
|
||
using Oms.Application.Dtos.Orders;
|
||
using Oms.Domain.ChannelsAggregate.Entities;
|
||
|
||
namespace Oms.Application.Shared;
|
||
|
||
/// <summary>
|
||
/// 地理分区规则(价格/黑名单/偏远共用)。
|
||
/// </summary>
|
||
public sealed class GeoRule
|
||
{
|
||
public int Ordinal { get; init; }
|
||
|
||
public object? Tag { get; init; }
|
||
|
||
public string CountryCode { get; init; } = string.Empty;
|
||
|
||
/// <summary>zip / state / city / country</summary>
|
||
public string MatchDim { get; init; } = string.Empty;
|
||
|
||
/// <summary>prefix / exact / range / all</summary>
|
||
public string MatchMode { get; init; } = string.Empty;
|
||
|
||
public int PrefixLen { get; init; }
|
||
|
||
public string MatchTextStd { get; init; } = string.Empty;
|
||
|
||
public string ZipStartStd { get; init; } = string.Empty;
|
||
|
||
public string ZipEndStd { get; init; } = string.Empty;
|
||
|
||
public string MatchStateStd { get; init; } = string.Empty;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 地址清洗、规则编译、单套分区匹配。价格/黑名单/偏远分区共用。
|
||
/// </summary>
|
||
public static class GeoPartitionMatcher
|
||
{
|
||
public const string DimZip = "zip";
|
||
public const string DimState = "state";
|
||
public const string DimCity = "city";
|
||
public const string DimCountry = "country";
|
||
|
||
public const string ModePrefix = "prefix";
|
||
public const string ModeExact = "exact";
|
||
public const string ModeRange = "range";
|
||
public const string ModeAll = "all";
|
||
|
||
private static readonly Regex PostcodeRangePattern = new(
|
||
@"^[\[(]\s*([^,)\]]*)\s*,\s*([^,)\]]*)\s*[\])]$",
|
||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||
|
||
public static string NormalizeCountry(string? value) =>
|
||
string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
|
||
|
||
public static string NormalizeZip(string? value)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(value))
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
var chars = value.Where(ch => !char.IsWhiteSpace(ch) && ch != '-').ToArray();
|
||
return chars.Length == 0 ? string.Empty : new string(chars).ToUpperInvariant();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 规范化后的邮编比较:纯数字按等长补零;相同字母前缀+数字后缀按数值;其余按 Ordinal。
|
||
/// </summary>
|
||
public static int CompareNormalizedZip(string? left, string? right)
|
||
{
|
||
var a = left ?? string.Empty;
|
||
var b = right ?? string.Empty;
|
||
if (a.Length == 0 && b.Length == 0)
|
||
{
|
||
return 0;
|
||
}
|
||
|
||
if (IsDigits(a) && IsDigits(b))
|
||
{
|
||
var width = Math.Max(a.Length, b.Length);
|
||
return string.CompareOrdinal(a.PadLeft(width, '0'), b.PadLeft(width, '0'));
|
||
}
|
||
|
||
if (TrySplitLetterPrefixNumber(a, out var prefixA, out var numberA, out var restA)
|
||
&& TrySplitLetterPrefixNumber(b, out var prefixB, out var numberB, out var restB)
|
||
&& string.Equals(prefixA, prefixB, StringComparison.Ordinal))
|
||
{
|
||
var numberCmp = numberA.CompareTo(numberB);
|
||
return numberCmp != 0 ? numberCmp : string.CompareOrdinal(restA, restB);
|
||
}
|
||
|
||
return string.CompareOrdinal(a, b);
|
||
}
|
||
|
||
private static bool IsDigits(string value) =>
|
||
value.Length > 0 && value.All(char.IsAsciiDigit);
|
||
|
||
private static bool TrySplitLetterPrefixNumber(
|
||
string zip,
|
||
out string prefix,
|
||
out int number,
|
||
out string rest)
|
||
{
|
||
prefix = string.Empty;
|
||
number = 0;
|
||
rest = string.Empty;
|
||
if (zip.Length == 0 || !char.IsAsciiLetter(zip[0]))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var i = 0;
|
||
while (i < zip.Length && char.IsAsciiLetter(zip[i]))
|
||
{
|
||
i++;
|
||
}
|
||
|
||
if (i >= zip.Length || !char.IsAsciiDigit(zip[i]))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
prefix = zip[..i];
|
||
var digitStart = i;
|
||
while (i < zip.Length && char.IsAsciiDigit(zip[i]))
|
||
{
|
||
i++;
|
||
}
|
||
|
||
if (!int.TryParse(zip[digitStart..i], NumberStyles.None, CultureInfo.InvariantCulture, out number))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
rest = zip[i..];
|
||
return true;
|
||
}
|
||
|
||
public static string NormalizeRegion(string? value) =>
|
||
string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
|
||
|
||
/// <summary>
|
||
/// 执行单套地理分区匹配。调用方需传入已排序规则:zip > state > city > country,同维度 prefixLen 降序。
|
||
/// </summary>
|
||
public static GeoRule? MatchSinglePartition(
|
||
string countryCode,
|
||
string zipStd,
|
||
string stateStd,
|
||
string cityStd,
|
||
IReadOnlyList<GeoRule> ruleList)
|
||
{
|
||
var country = NormalizeCountry(countryCode);
|
||
zipStd ??= string.Empty;
|
||
stateStd ??= string.Empty;
|
||
cityStd ??= string.Empty;
|
||
if (ruleList == null || ruleList.Count == 0 || string.IsNullOrEmpty(country))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
foreach (var rule in ruleList)
|
||
{
|
||
if (!string.Equals(rule.CountryCode, country, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (rule.MatchDim == DimZip)
|
||
{
|
||
if (string.IsNullOrEmpty(zipStd))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var hit = false;
|
||
if (rule.MatchMode == ModePrefix)
|
||
{
|
||
if (rule.PrefixLen > 0 && zipStd.Length >= rule.PrefixLen)
|
||
{
|
||
var userPrefix = zipStd[..rule.PrefixLen];
|
||
hit = userPrefix == rule.MatchTextStd;
|
||
}
|
||
}
|
||
else if (rule.MatchMode == ModeExact)
|
||
{
|
||
hit = zipStd == rule.MatchTextStd;
|
||
}
|
||
else if (rule.MatchMode == ModeRange)
|
||
{
|
||
hit = CompareNormalizedZip(zipStd, rule.ZipStartStd) >= 0
|
||
&& CompareNormalizedZip(zipStd, rule.ZipEndStd) <= 0;
|
||
}
|
||
|
||
if (hit)
|
||
{
|
||
return rule;
|
||
}
|
||
}
|
||
else if (rule.MatchDim == DimState)
|
||
{
|
||
if (!string.IsNullOrEmpty(zipStd))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (rule.MatchMode == ModeExact && stateStd == rule.MatchTextStd && !string.IsNullOrEmpty(stateStd))
|
||
{
|
||
return rule;
|
||
}
|
||
}
|
||
else if (rule.MatchDim == DimCity)
|
||
{
|
||
if (!string.IsNullOrEmpty(zipStd))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (rule.MatchMode == ModeExact
|
||
&& rule.MatchStateStd == stateStd
|
||
&& rule.MatchTextStd == cityStd
|
||
&& !string.IsNullOrEmpty(stateStd)
|
||
&& !string.IsNullOrEmpty(cityStd))
|
||
{
|
||
return rule;
|
||
}
|
||
}
|
||
else if (rule.MatchDim == DimCountry)
|
||
{
|
||
return rule;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
public static IReadOnlyList<GeoRule> SortRules(IEnumerable<GeoRule> rules) =>
|
||
rules
|
||
.OrderBy(DimRank)
|
||
.ThenByDescending(r => r.PrefixLen)
|
||
.ThenByDescending(RangeSpecificity)
|
||
.ThenBy(r => r.Ordinal)
|
||
.ToList();
|
||
|
||
public static GeoRule? Compile(KpiAreaInfo entity, int ordinal = 0, bool attachTag = true)
|
||
{
|
||
if (entity == null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return Compile(
|
||
entity.CountryCode,
|
||
entity.Province,
|
||
entity.City,
|
||
entity.LeftPostcode,
|
||
entity.RightPostcode,
|
||
null,
|
||
entity.MatchDim,
|
||
entity.MatchMode,
|
||
entity.PrefixLen,
|
||
ordinal,
|
||
attachTag ? entity : null);
|
||
}
|
||
|
||
public static GeoRule? Compile(KpiAreaSnapshotRuleDto rule, int ordinal = 0, bool attachTag = true)
|
||
{
|
||
if (rule == null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var range = KpiAreaSnapshotMapper.ResolvePostcodeRange(rule);
|
||
return Compile(
|
||
rule.CountryCode,
|
||
rule.Province,
|
||
rule.City,
|
||
rule.LeftPostcode,
|
||
rule.RightPostcode,
|
||
range,
|
||
rule.MatchDim,
|
||
rule.MatchMode,
|
||
rule.PrefixLen,
|
||
ordinal,
|
||
attachTag ? rule : null);
|
||
}
|
||
|
||
public static KpiAreaSnapshotRuleDto? MatchSnapshot(
|
||
IEnumerable<KpiAreaSnapshotRuleDto>? infos,
|
||
string countryCode,
|
||
string? province,
|
||
string? city,
|
||
string? zipCode)
|
||
{
|
||
var hit = Match(CompileList(infos, Compile), countryCode, province, city, zipCode);
|
||
return hit?.Tag as KpiAreaSnapshotRuleDto;
|
||
}
|
||
|
||
public static KpiAreaInfo? MatchEntity(
|
||
IEnumerable<KpiAreaInfo>? infos,
|
||
string countryCode,
|
||
string? province,
|
||
string? city,
|
||
string? zipCode)
|
||
{
|
||
var hit = Match(CompileList(infos, Compile), countryCode, province, city, zipCode);
|
||
return hit?.Tag as KpiAreaInfo;
|
||
}
|
||
|
||
public static bool IsHit(
|
||
IEnumerable<KpiAreaInfo>? infos,
|
||
string countryCode,
|
||
string? province,
|
||
string? city,
|
||
string? zipCode) =>
|
||
MatchEntity(infos, countryCode, province, city, zipCode) != null;
|
||
|
||
public static bool IsHit(
|
||
IEnumerable<KpiAreaSnapshotRuleDto>? infos,
|
||
string countryCode,
|
||
string? province,
|
||
string? city,
|
||
string? zipCode) =>
|
||
MatchSnapshot(infos, countryCode, province, city, zipCode) != null;
|
||
|
||
public static GeoRule? Match(
|
||
IReadOnlyList<GeoRule> compiled,
|
||
string countryCode,
|
||
string? province,
|
||
string? city,
|
||
string? zipCode)
|
||
{
|
||
var sorted = SortRules(compiled);
|
||
return MatchSinglePartition(
|
||
NormalizeCountry(countryCode),
|
||
NormalizeZip(zipCode),
|
||
NormalizeRegion(province),
|
||
NormalizeRegion(city),
|
||
sorted);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 清洗并收口为一维规则,写入 MatchDim / MatchMode / PrefixLen。
|
||
/// 显式维度优先;未传则按字段推断。
|
||
/// </summary>
|
||
public static void PrepareEntity(KpiAreaInfo entity)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(entity);
|
||
entity.CountryCode = string.IsNullOrWhiteSpace(entity.CountryCode) ? null : entity.CountryCode.Trim();
|
||
entity.CountryName = string.IsNullOrWhiteSpace(entity.CountryName) ? null : entity.CountryName.Trim();
|
||
entity.Province = EmptyToNull(entity.Province);
|
||
entity.City = EmptyToNull(entity.City);
|
||
entity.LeftPostcode = EmptyToNull(entity.LeftPostcode);
|
||
entity.RightPostcode = EmptyToNull(entity.RightPostcode);
|
||
|
||
if (string.IsNullOrWhiteSpace(entity.CountryCode))
|
||
{
|
||
throw new InvalidOperationException("国家代码不能为空");
|
||
}
|
||
|
||
var dim = NormalizeDim(entity.MatchDim);
|
||
var mode = NormalizeMode(entity.MatchMode);
|
||
if (string.IsNullOrEmpty(dim))
|
||
{
|
||
dim = InferDim(entity.LeftPostcode, entity.RightPostcode, entity.Province, entity.City);
|
||
}
|
||
|
||
switch (dim)
|
||
{
|
||
case DimZip:
|
||
ApplyZipFields(entity, mode);
|
||
break;
|
||
case DimState:
|
||
if (string.IsNullOrWhiteSpace(entity.Province))
|
||
{
|
||
throw new InvalidOperationException("州/省匹配必须填写州/省");
|
||
}
|
||
|
||
entity.City = null;
|
||
entity.LeftPostcode = null;
|
||
entity.RightPostcode = null;
|
||
entity.MatchDim = DimState;
|
||
entity.MatchMode = ModeExact;
|
||
entity.PrefixLen = 0;
|
||
break;
|
||
case DimCity:
|
||
if (string.IsNullOrWhiteSpace(entity.Province) || string.IsNullOrWhiteSpace(entity.City))
|
||
{
|
||
throw new InvalidOperationException("城市匹配必须同时填写州/省");
|
||
}
|
||
|
||
entity.LeftPostcode = null;
|
||
entity.RightPostcode = null;
|
||
entity.MatchDim = DimCity;
|
||
entity.MatchMode = ModeExact;
|
||
entity.PrefixLen = 0;
|
||
break;
|
||
default:
|
||
entity.Province = null;
|
||
entity.City = null;
|
||
entity.LeftPostcode = null;
|
||
entity.RightPostcode = null;
|
||
entity.MatchDim = DimCountry;
|
||
entity.MatchMode = ModeAll;
|
||
entity.PrefixLen = 0;
|
||
break;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 同一分区内判定完全相同规则的指纹(不比较 AreaId)。
|
||
/// 须在 PrepareEntity 之后调用。
|
||
/// </summary>
|
||
public static string BuildDuplicateKey(KpiAreaInfo entity)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(entity);
|
||
var dim = NormalizeDim(entity.MatchDim) ?? DimCountry;
|
||
var mode = NormalizeMode(entity.MatchMode) ?? ModeAll;
|
||
var country = NormKey(entity.CountryCode);
|
||
|
||
return dim switch
|
||
{
|
||
DimState => $"{DimState}|{country}|{NormKey(entity.Province)}",
|
||
DimCity => $"{DimCity}|{country}|{NormKey(entity.Province)}|{NormKey(entity.City)}",
|
||
DimZip when mode == ModePrefix =>
|
||
$"{DimZip}|{ModePrefix}|{country}|{NormKey(entity.LeftPostcode)}|{entity.PrefixLen}",
|
||
DimZip when mode == ModeExact =>
|
||
$"{DimZip}|{ModeExact}|{country}|{NormKey(entity.LeftPostcode)}",
|
||
DimZip =>
|
||
$"{DimZip}|{ModeRange}|{country}|{NormKey(entity.LeftPostcode)}|{NormKey(entity.RightPostcode)}",
|
||
_ => $"{DimCountry}|{country}"
|
||
};
|
||
}
|
||
|
||
private static string NormKey(string? value) => (value ?? string.Empty).Trim().ToUpperInvariant();
|
||
|
||
public static (string MatchDim, string MatchMode, int PrefixLen) Describe(KpiAreaInfo? entity)
|
||
{
|
||
if (entity == null)
|
||
{
|
||
return (DimCountry, ModeAll, 0);
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(entity.MatchDim))
|
||
{
|
||
return (
|
||
NormalizeDim(entity.MatchDim) ?? DimCountry,
|
||
NormalizeMode(entity.MatchMode) ?? ModeAll,
|
||
entity.PrefixLen);
|
||
}
|
||
|
||
var rule = Compile(entity, attachTag: false);
|
||
if (rule == null)
|
||
{
|
||
return (DimCountry, ModeAll, 0);
|
||
}
|
||
|
||
return (rule.MatchDim, rule.MatchMode, rule.PrefixLen);
|
||
}
|
||
|
||
private static GeoRule? Compile(
|
||
string? countryCode,
|
||
string? province,
|
||
string? city,
|
||
string? leftPostcode,
|
||
string? rightPostcode,
|
||
string? postcodeRange,
|
||
string? matchDim,
|
||
string? matchMode,
|
||
int prefixLenHint,
|
||
int ordinal,
|
||
object? tag)
|
||
{
|
||
var country = NormalizeCountry(countryCode);
|
||
if (string.IsNullOrEmpty(country))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var dim = NormalizeDim(matchDim);
|
||
if (string.IsNullOrEmpty(dim))
|
||
{
|
||
dim = InferDim(leftPostcode, rightPostcode, province, city, postcodeRange);
|
||
}
|
||
|
||
var encoded = TryParseEncodedRange(postcodeRange);
|
||
var zipLeft = NormalizeZip(leftPostcode);
|
||
var zipRight = NormalizeZip(rightPostcode);
|
||
var zipMode = NormalizeMode(matchMode) ?? encoded?.Mode;
|
||
var zipText = encoded?.Text ?? string.Empty;
|
||
var zipStart = encoded?.Start ?? string.Empty;
|
||
var zipEnd = encoded?.End ?? string.Empty;
|
||
var prefixLen = prefixLenHint > 0 ? prefixLenHint : encoded?.PrefixLen ?? 0;
|
||
|
||
if (zipMode is not (ModePrefix or ModeExact or ModeRange))
|
||
{
|
||
zipMode = encoded?.Mode;
|
||
}
|
||
|
||
if (zipMode == null)
|
||
{
|
||
if (zipLeft.Length > 0 && zipRight.Length == 0)
|
||
{
|
||
zipMode = ModePrefix;
|
||
zipText = zipLeft;
|
||
prefixLen = zipLeft.Length;
|
||
}
|
||
else if (zipLeft.Length > 0 && zipLeft == zipRight)
|
||
{
|
||
zipMode = ModeExact;
|
||
zipText = zipLeft;
|
||
prefixLen = zipLeft.Length;
|
||
}
|
||
else if (zipLeft.Length > 0 && zipRight.Length > 0)
|
||
{
|
||
zipMode = ModeRange;
|
||
zipStart = zipLeft;
|
||
zipEnd = zipRight;
|
||
prefixLen = Math.Min(zipLeft.Length, zipRight.Length);
|
||
}
|
||
}
|
||
else if (dim == DimZip)
|
||
{
|
||
if (zipMode == ModePrefix)
|
||
{
|
||
zipText = zipLeft.Length > 0 ? zipLeft : zipText;
|
||
prefixLen = prefixLen > 0 ? prefixLen : zipText.Length;
|
||
}
|
||
else if (zipMode == ModeExact)
|
||
{
|
||
zipText = zipLeft.Length > 0 ? zipLeft : zipText;
|
||
prefixLen = zipText.Length;
|
||
}
|
||
else if (zipMode == ModeRange)
|
||
{
|
||
zipStart = zipLeft.Length > 0 ? zipLeft : zipStart;
|
||
zipEnd = zipRight.Length > 0 ? zipRight : zipEnd;
|
||
prefixLen = Math.Min(
|
||
zipStart.Length == 0 ? prefixLen : zipStart.Length,
|
||
zipEnd.Length == 0 ? prefixLen : zipEnd.Length);
|
||
}
|
||
}
|
||
|
||
if (dim == DimZip)
|
||
{
|
||
if (zipMode == null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return new GeoRule
|
||
{
|
||
Ordinal = ordinal,
|
||
Tag = tag,
|
||
CountryCode = country,
|
||
MatchDim = DimZip,
|
||
MatchMode = zipMode,
|
||
PrefixLen = prefixLen,
|
||
MatchTextStd = zipText,
|
||
ZipStartStd = zipStart,
|
||
ZipEndStd = zipEnd
|
||
};
|
||
}
|
||
|
||
var state = NormalizeRegion(province);
|
||
var cityStd = NormalizeRegion(city);
|
||
if (dim == DimCity)
|
||
{
|
||
if (string.IsNullOrEmpty(state) || string.IsNullOrEmpty(cityStd))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return new GeoRule
|
||
{
|
||
Ordinal = ordinal,
|
||
Tag = tag,
|
||
CountryCode = country,
|
||
MatchDim = DimCity,
|
||
MatchMode = ModeExact,
|
||
MatchTextStd = cityStd,
|
||
MatchStateStd = state
|
||
};
|
||
}
|
||
|
||
if (dim == DimState)
|
||
{
|
||
if (string.IsNullOrEmpty(state))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return new GeoRule
|
||
{
|
||
Ordinal = ordinal,
|
||
Tag = tag,
|
||
CountryCode = country,
|
||
MatchDim = DimState,
|
||
MatchMode = ModeExact,
|
||
MatchTextStd = state
|
||
};
|
||
}
|
||
|
||
return new GeoRule
|
||
{
|
||
Ordinal = ordinal,
|
||
Tag = tag,
|
||
CountryCode = country,
|
||
MatchDim = DimCountry,
|
||
MatchMode = ModeAll
|
||
};
|
||
}
|
||
|
||
private static void ApplyZipFields(KpiAreaInfo entity, string? requestedMode)
|
||
{
|
||
entity.Province = null;
|
||
entity.City = null;
|
||
var zipLeft = NormalizeZip(entity.LeftPostcode);
|
||
var zipRight = NormalizeZip(entity.RightPostcode);
|
||
if (zipLeft.Length == 0 && zipRight.Length > 0)
|
||
{
|
||
zipLeft = zipRight;
|
||
zipRight = string.Empty;
|
||
}
|
||
|
||
if (zipLeft.Length == 0)
|
||
{
|
||
throw new InvalidOperationException("邮编匹配必须填写起始邮编");
|
||
}
|
||
|
||
var mode = requestedMode;
|
||
if (mode is not (ModePrefix or ModeExact or ModeRange))
|
||
{
|
||
if (zipRight.Length == 0)
|
||
{
|
||
mode = ModePrefix;
|
||
}
|
||
else if (zipLeft == zipRight)
|
||
{
|
||
mode = ModeExact;
|
||
}
|
||
else
|
||
{
|
||
mode = ModeRange;
|
||
}
|
||
}
|
||
|
||
if (mode == ModePrefix)
|
||
{
|
||
entity.LeftPostcode = zipLeft;
|
||
entity.RightPostcode = null;
|
||
entity.PrefixLen = zipLeft.Length;
|
||
}
|
||
else if (mode == ModeExact)
|
||
{
|
||
entity.LeftPostcode = zipLeft;
|
||
entity.RightPostcode = zipLeft;
|
||
entity.PrefixLen = zipLeft.Length;
|
||
}
|
||
else
|
||
{
|
||
if (zipRight.Length == 0)
|
||
{
|
||
throw new InvalidOperationException("邮编区间必须填写截止邮编");
|
||
}
|
||
|
||
if (CompareNormalizedZip(zipLeft, zipRight) > 0)
|
||
{
|
||
throw new InvalidOperationException("起始邮编不能大于截止邮编");
|
||
}
|
||
|
||
entity.LeftPostcode = zipLeft;
|
||
entity.RightPostcode = zipRight;
|
||
entity.PrefixLen = Math.Min(zipLeft.Length, zipRight.Length);
|
||
}
|
||
|
||
entity.MatchDim = DimZip;
|
||
entity.MatchMode = mode;
|
||
}
|
||
|
||
private static string InferDim(
|
||
string? leftPostcode,
|
||
string? rightPostcode,
|
||
string? province,
|
||
string? city,
|
||
string? postcodeRange = null)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(postcodeRange)
|
||
|| !string.IsNullOrWhiteSpace(leftPostcode)
|
||
|| !string.IsNullOrWhiteSpace(rightPostcode))
|
||
{
|
||
return DimZip;
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(city))
|
||
{
|
||
return DimCity;
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(province))
|
||
{
|
||
return DimState;
|
||
}
|
||
|
||
return DimCountry;
|
||
}
|
||
|
||
private static string? NormalizeDim(string? value)
|
||
{
|
||
var raw = value?.Trim().ToLowerInvariant();
|
||
return raw switch
|
||
{
|
||
DimZip or "postcode" or "postal" or "邮编" => DimZip,
|
||
DimState or "province" or "州" or "州/省" or "省" => DimState,
|
||
DimCity or "城市" => DimCity,
|
||
DimCountry or "国家" or "仅国家" => DimCountry,
|
||
_ => null
|
||
};
|
||
}
|
||
|
||
private static string? NormalizeMode(string? value)
|
||
{
|
||
var raw = value?.Trim().ToLowerInvariant();
|
||
return raw switch
|
||
{
|
||
ModePrefix or "前缀" => ModePrefix,
|
||
ModeExact or "精确" or "exactmatch" => ModeExact,
|
||
ModeRange or "区间" => ModeRange,
|
||
ModeAll or "全部" => ModeAll,
|
||
_ => null
|
||
};
|
||
}
|
||
|
||
private static EncodedZip? TryParseEncodedRange(string? postcodeRange)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(postcodeRange))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var raw = postcodeRange.Trim();
|
||
if (raw.StartsWith("prefix:", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var text = NormalizeZip(raw["prefix:".Length..]);
|
||
if (text.Length == 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return new EncodedZip(ModePrefix, text, string.Empty, string.Empty, text.Length);
|
||
}
|
||
|
||
if (raw.StartsWith("exact:", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var text = NormalizeZip(raw["exact:".Length..]);
|
||
if (text.Length == 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return new EncodedZip(ModeExact, text, string.Empty, string.Empty, text.Length);
|
||
}
|
||
|
||
var match = PostcodeRangePattern.Match(raw);
|
||
if (!match.Success)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var left = NormalizeZip(match.Groups[1].Value);
|
||
var right = NormalizeZip(match.Groups[2].Value);
|
||
if (left.Length == 0 && right.Length == 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
if (left.Length == 0)
|
||
{
|
||
left = right;
|
||
}
|
||
|
||
if (right.Length == 0)
|
||
{
|
||
return new EncodedZip(ModePrefix, left, string.Empty, string.Empty, left.Length);
|
||
}
|
||
|
||
if (left == right)
|
||
{
|
||
return new EncodedZip(ModeExact, left, string.Empty, string.Empty, left.Length);
|
||
}
|
||
|
||
return new EncodedZip(ModeRange, string.Empty, left, right, Math.Min(left.Length, right.Length));
|
||
}
|
||
|
||
private static List<GeoRule> CompileList<T>(IEnumerable<T>? sources, Func<T, int, bool, GeoRule?> compile)
|
||
{
|
||
var list = new List<GeoRule>();
|
||
if (sources == null)
|
||
{
|
||
return list;
|
||
}
|
||
|
||
var ordinal = 0;
|
||
foreach (var source in sources)
|
||
{
|
||
var rule = compile(source, ordinal++, true);
|
||
if (rule != null)
|
||
{
|
||
list.Add(rule);
|
||
}
|
||
}
|
||
|
||
return list;
|
||
}
|
||
|
||
private static int DimRank(GeoRule rule) =>
|
||
rule.MatchDim switch
|
||
{
|
||
DimZip => 1,
|
||
DimState => 2,
|
||
DimCity => 3,
|
||
DimCountry => 4,
|
||
_ => 9
|
||
};
|
||
|
||
private static int RangeSpecificity(GeoRule rule)
|
||
{
|
||
if (rule.MatchDim != DimZip)
|
||
{
|
||
return 0;
|
||
}
|
||
|
||
if (rule.MatchMode == ModeExact)
|
||
{
|
||
return rule.MatchTextStd.Length * 10;
|
||
}
|
||
|
||
if (rule.MatchMode == ModeRange)
|
||
{
|
||
return Math.Min(rule.ZipStartStd.Length, rule.ZipEndStd.Length);
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
private static string? EmptyToNull(string? value) =>
|
||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||
|
||
private sealed record EncodedZip(string Mode, string Text, string Start, string End, int PrefixLen);
|
||
}
|