feat(geo): add tenant/merchant/store geocode fallback and retry workflow
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s

This commit is contained in:
2026-02-19 17:13:00 +08:00
parent ad245078a2
commit 53f7c54c82
33 changed files with 9514 additions and 11 deletions

View File

@@ -0,0 +1,23 @@
namespace TakeoutSaaS.Application.App.Common.Geo;
/// <summary>
/// 地址地理编码结果。
/// </summary>
public sealed record AddressGeocodingResult(
bool Succeeded,
decimal? Latitude,
decimal? Longitude,
string? Message)
{
/// <summary>
/// 构建成功结果。
/// </summary>
public static AddressGeocodingResult Success(decimal latitude, decimal longitude)
=> new(true, latitude, longitude, null);
/// <summary>
/// 构建失败结果。
/// </summary>
public static AddressGeocodingResult Failed(string? message)
=> new(false, null, null, message);
}

View File

@@ -0,0 +1,82 @@
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Tenants.Entities;
namespace TakeoutSaaS.Application.App.Common.Geo;
/// <summary>
/// 地理定位地址构建器。
/// </summary>
public static class GeoAddressBuilder
{
/// <summary>
/// 构建门店地理编码候选地址。
/// </summary>
public static IReadOnlyList<string> BuildStoreCandidates(Store store, Merchant? merchant = null)
{
var candidates = new List<string>();
AddCandidate(candidates, BuildAddress(store.Province, store.City, store.District, store.Address));
AddCandidate(candidates, store.Address);
if (merchant is not null)
{
AddCandidate(candidates, BuildAddress(merchant.Province, merchant.City, merchant.District, merchant.Address));
AddCandidate(candidates, BuildAddress(merchant.Province, merchant.City, merchant.District));
AddCandidate(candidates, BuildAddress(merchant.City, merchant.District));
AddCandidate(candidates, merchant.Address);
}
return candidates;
}
/// <summary>
/// 构建商户地理编码候选地址。
/// </summary>
public static IReadOnlyList<string> BuildMerchantCandidates(Merchant merchant)
{
var candidates = new List<string>();
AddCandidate(candidates, BuildAddress(merchant.Province, merchant.City, merchant.District, merchant.Address));
AddCandidate(candidates, merchant.Address);
AddCandidate(candidates, BuildAddress(merchant.Province, merchant.City, merchant.District));
AddCandidate(candidates, BuildAddress(merchant.City, merchant.District));
return candidates;
}
/// <summary>
/// 构建租户地理编码候选地址。
/// </summary>
public static IReadOnlyList<string> BuildTenantCandidates(Tenant tenant)
{
var candidates = new List<string>();
AddCandidate(candidates, BuildAddress(tenant.Country, tenant.Province, tenant.City, tenant.Address));
AddCandidate(candidates, tenant.Address);
AddCandidate(candidates, BuildAddress(tenant.Country, tenant.Province, tenant.City));
AddCandidate(candidates, BuildAddress(tenant.Province, tenant.City));
return candidates;
}
private static string BuildAddress(params string?[] parts)
{
var normalized = parts
.Select(part => part?.Trim())
.Where(part => !string.IsNullOrWhiteSpace(part))
.ToArray();
return normalized.Length == 0 ? string.Empty : string.Join(string.Empty, normalized);
}
private static void AddCandidate(ICollection<string> candidates, string? candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return;
}
if (candidates.Any(existing => string.Equals(existing, candidate, StringComparison.OrdinalIgnoreCase)))
{
return;
}
candidates.Add(candidate);
}
}

View File

@@ -0,0 +1,164 @@
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Tenants.Entities;
namespace TakeoutSaaS.Application.App.Common.Geo;
/// <summary>
/// 地理定位状态写入助手。
/// </summary>
public static class GeoLocationStateHelper
{
/// <summary>
/// 最大重试次数。
/// </summary>
public const int MaxRetryCount = 5;
/// <summary>
/// 获取重试间隔。
/// </summary>
public static TimeSpan GetRetryDelay(int retryCount)
{
return retryCount switch
{
<= 1 => TimeSpan.FromMinutes(1),
2 => TimeSpan.FromMinutes(5),
3 => TimeSpan.FromMinutes(30),
4 => TimeSpan.FromHours(2),
_ => TimeSpan.FromHours(12)
};
}
/// <summary>
/// 写入门店定位成功状态。
/// </summary>
public static void MarkSuccess(Store store, double latitude, double longitude, DateTime now)
{
store.Latitude = latitude;
store.Longitude = longitude;
store.GeoStatus = GeoLocationStatus.Success;
store.GeoFailReason = null;
store.GeoRetryCount = 0;
store.GeoUpdatedAt = now;
store.GeoNextRetryAt = null;
}
/// <summary>
/// 写入商户定位成功状态。
/// </summary>
public static void MarkSuccess(Merchant merchant, double latitude, double longitude, DateTime now)
{
merchant.Latitude = latitude;
merchant.Longitude = longitude;
merchant.GeoStatus = GeoLocationStatus.Success;
merchant.GeoFailReason = null;
merchant.GeoRetryCount = 0;
merchant.GeoUpdatedAt = now;
merchant.GeoNextRetryAt = null;
}
/// <summary>
/// 写入租户定位成功状态。
/// </summary>
public static void MarkSuccess(Tenant tenant, double latitude, double longitude, DateTime now)
{
tenant.Latitude = latitude;
tenant.Longitude = longitude;
tenant.GeoStatus = GeoLocationStatus.Success;
tenant.GeoFailReason = null;
tenant.GeoRetryCount = 0;
tenant.GeoUpdatedAt = now;
tenant.GeoNextRetryAt = null;
}
/// <summary>
/// 写入门店待重试状态。
/// </summary>
public static void MarkPending(Store store, string? reason, DateTime now)
{
store.GeoStatus = GeoLocationStatus.Pending;
store.GeoFailReason = reason;
store.GeoRetryCount = 0;
store.GeoNextRetryAt = now.Add(GetRetryDelay(1));
}
/// <summary>
/// 写入商户待重试状态。
/// </summary>
public static void MarkPending(Merchant merchant, string? reason, DateTime now)
{
merchant.GeoStatus = GeoLocationStatus.Pending;
merchant.GeoFailReason = reason;
merchant.GeoRetryCount = 0;
merchant.GeoNextRetryAt = now.Add(GetRetryDelay(1));
}
/// <summary>
/// 写入租户待重试状态。
/// </summary>
public static void MarkPending(Tenant tenant, string? reason, DateTime now)
{
tenant.GeoStatus = GeoLocationStatus.Pending;
tenant.GeoFailReason = reason;
tenant.GeoRetryCount = 0;
tenant.GeoNextRetryAt = now.Add(GetRetryDelay(1));
}
/// <summary>
/// 写入门店重试失败状态。
/// </summary>
public static void MarkRetryFailure(Store store, string? reason, DateTime now)
{
var nextRetryCount = store.GeoRetryCount + 1;
store.GeoRetryCount = nextRetryCount;
store.GeoFailReason = reason;
if (nextRetryCount >= MaxRetryCount)
{
store.GeoStatus = GeoLocationStatus.Failed;
store.GeoNextRetryAt = null;
return;
}
store.GeoStatus = GeoLocationStatus.Pending;
store.GeoNextRetryAt = now.Add(GetRetryDelay(nextRetryCount + 1));
}
/// <summary>
/// 写入商户重试失败状态。
/// </summary>
public static void MarkRetryFailure(Merchant merchant, string? reason, DateTime now)
{
var nextRetryCount = merchant.GeoRetryCount + 1;
merchant.GeoRetryCount = nextRetryCount;
merchant.GeoFailReason = reason;
if (nextRetryCount >= MaxRetryCount)
{
merchant.GeoStatus = GeoLocationStatus.Failed;
merchant.GeoNextRetryAt = null;
return;
}
merchant.GeoStatus = GeoLocationStatus.Pending;
merchant.GeoNextRetryAt = now.Add(GetRetryDelay(nextRetryCount + 1));
}
/// <summary>
/// 写入租户重试失败状态。
/// </summary>
public static void MarkRetryFailure(Tenant tenant, string? reason, DateTime now)
{
var nextRetryCount = tenant.GeoRetryCount + 1;
tenant.GeoRetryCount = nextRetryCount;
tenant.GeoFailReason = reason;
if (nextRetryCount >= MaxRetryCount)
{
tenant.GeoStatus = GeoLocationStatus.Failed;
tenant.GeoNextRetryAt = null;
return;
}
tenant.GeoStatus = GeoLocationStatus.Pending;
tenant.GeoNextRetryAt = now.Add(GetRetryDelay(nextRetryCount + 1));
}
}

View File

@@ -0,0 +1,15 @@
namespace TakeoutSaaS.Application.App.Common.Geo;
/// <summary>
/// 地址地理编码服务契约。
/// </summary>
public interface IAddressGeocodingService
{
/// <summary>
/// 将地址解析为经纬度。
/// </summary>
/// <param name="address">地址文本。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>地理编码结果。</returns>
Task<AddressGeocodingResult> GeocodeAsync(string address, CancellationToken cancellationToken);
}