using System.Globalization; using System.Text.RegularExpressions; using Oms.Application.Dtos.Orders; using Oms.Domain.ChannelsAggregate.Entities; namespace Oms.Application.Shared; /// /// 地理分区规则(价格/黑名单/偏远共用)。 /// public sealed class GeoRule { public int Ordinal { get; init; } public object? Tag { get; init; } public string CountryCode { get; init; } = string.Empty; /// zip / state / city / country public string MatchDim { get; init; } = string.Empty; /// prefix / exact / range / all 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; } /// /// 地址清洗、规则编译、单套分区匹配。价格/黑名单/偏远分区共用。 /// 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(); } /// /// 规范化后的邮编比较:纯数字按等长补零;相同字母前缀+数字后缀按数值;其余按 Ordinal。 /// 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(); /// /// 执行单套地理分区匹配。调用方需传入已排序规则:zip > state > city > country,同维度 prefixLen 降序。 /// public static GeoRule? MatchSinglePartition( string countryCode, string zipStd, string stateStd, string cityStd, IReadOnlyList 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 SortRules(IEnumerable 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? 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? 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? infos, string countryCode, string? province, string? city, string? zipCode) => MatchEntity(infos, countryCode, province, city, zipCode) != null; public static bool IsHit( IEnumerable? infos, string countryCode, string? province, string? city, string? zipCode) => MatchSnapshot(infos, countryCode, province, city, zipCode) != null; public static GeoRule? Match( IReadOnlyList compiled, string countryCode, string? province, string? city, string? zipCode) { var sorted = SortRules(compiled); return MatchSinglePartition( NormalizeCountry(countryCode), NormalizeZip(zipCode), NormalizeRegion(province), NormalizeRegion(city), sorted); } /// /// 清洗并收口为一维规则,写入 MatchDim / MatchMode / PrefixLen。 /// 显式维度优先;未传则按字段推断。 /// 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; } } /// /// 同一分区内判定完全相同规则的指纹(不比较 AreaId)。 /// 须在 PrepareEntity 之后调用。 /// 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 CompileList(IEnumerable? sources, Func compile) { var list = new List(); 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); }