admin/src/Oms.Application/Shared/GeoPartitionMatcher.cs
2026-10-06 15:37:03 +08:00

853 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}