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

@@ -1,10 +1,14 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Services;
namespace TakeoutSaaS.TenantApi.Controllers;
@@ -14,7 +18,10 @@ namespace TakeoutSaaS.TenantApi.Controllers;
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/merchant")]
public sealed class MerchantController(IMediator mediator) : BaseApiController
public sealed class MerchantController(
IMediator mediator,
ITenantProvider tenantProvider,
GeoLocationOrchestrator geoLocationOrchestrator) : BaseApiController
{
/// <summary>
/// 获取当前登录用户对应的商户中心信息。
@@ -32,4 +39,106 @@ public sealed class MerchantController(IMediator mediator) : BaseApiController
// 2. 返回聚合信息
return ApiResponse<CurrentMerchantCenterDto>.Ok(info);
}
/// <summary>
/// 更新当前商户基础信息。
/// </summary>
/// <param name="request">更新请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新结果。</returns>
[HttpPost("update")]
[ProducesResponseType(typeof(ApiResponse<UpdateMerchantResultDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<UpdateMerchantResultDto>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<UpdateMerchantResultDto>), StatusCodes.Status422UnprocessableEntity)]
public async Task<ApiResponse<UpdateMerchantResultDto>> Update(
[FromBody] UpdateCurrentMerchantRequest request,
CancellationToken cancellationToken)
{
var merchantId = StoreApiHelpers.ParseRequiredSnowflake(request.Id, nameof(request.Id));
var result = await mediator.Send(new UpdateMerchantCommand
{
MerchantId = merchantId,
Name = request.Name,
LicenseNumber = request.LicenseNumber,
LegalRepresentative = request.LegalRepresentative,
RegisteredAddress = request.RegisteredAddress,
ContactPhone = request.ContactPhone,
ContactEmail = request.ContactEmail
}, cancellationToken);
if (result is null)
{
return ApiResponse<UpdateMerchantResultDto>.Error(ErrorCodes.NotFound, "商户不存在");
}
return ApiResponse<UpdateMerchantResultDto>.Ok(result);
}
/// <summary>
/// 手动重试当前商户地理定位。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>重试结果。</returns>
[HttpPost("geocode/retry")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<object>> RetryCurrentMerchantGeocode(CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
if (tenantId <= 0)
{
return ApiResponse<object>.Error(ErrorCodes.BadRequest, "缺少租户标识");
}
var currentMerchant = await mediator.Send(new GetCurrentMerchantCenterQuery(), cancellationToken);
var merchantId = currentMerchant.Merchant.Id;
var success = await geoLocationOrchestrator.RetryMerchantAsync(tenantId, merchantId, cancellationToken);
if (!success)
{
return ApiResponse<object>.Error(ErrorCodes.NotFound, "商户不存在");
}
return ApiResponse<object>.Ok(null);
}
}
/// <summary>
/// 更新当前商户请求。
/// </summary>
public sealed class UpdateCurrentMerchantRequest
{
/// <summary>
/// 商户标识。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 商户名称。
/// </summary>
public string? Name { get; set; }
/// <summary>
/// 营业执照号。
/// </summary>
public string? LicenseNumber { get; set; }
/// <summary>
/// 法人/负责人。
/// </summary>
public string? LegalRepresentative { get; set; }
/// <summary>
/// 注册地址。
/// </summary>
public string? RegisteredAddress { get; set; }
/// <summary>
/// 联系电话。
/// </summary>
public string? ContactPhone { get; set; }
/// <summary>
/// 联系邮箱。
/// </summary>
public string? ContactEmail { get; set; }
}

View File

@@ -4,8 +4,11 @@ using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Stores.Commands;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Application.App.Stores.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Services;
namespace TakeoutSaaS.TenantApi.Controllers;
@@ -15,7 +18,10 @@ namespace TakeoutSaaS.TenantApi.Controllers;
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/store")]
public sealed class StoreController(IMediator mediator) : BaseApiController
public sealed class StoreController(
IMediator mediator,
StoreContextService storeContextService,
GeoLocationOrchestrator geoLocationOrchestrator) : BaseApiController
{
/// <summary>
/// 查询门店列表。
@@ -129,5 +135,32 @@ public sealed class StoreController(IMediator mediator) : BaseApiController
// 2. 返回切换结果
return ApiResponse<StoreDto>.Ok(result);
}
/// <summary>
/// 手动重试门店地理定位。
/// </summary>
/// <param name="storeId">门店 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>重试结果。</returns>
[HttpPost("{storeId}/geocode/retry")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<ApiResponse<object>> RetryGeocode(string storeId, CancellationToken cancellationToken)
{
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
var success = await geoLocationOrchestrator.RetryStoreAsync(
tenantId,
merchantId,
parsedStoreId,
cancellationToken);
if (!success)
{
return ApiResponse<object>.Error(ErrorCodes.NotFound, "门店不存在或无权限访问");
}
return ApiResponse<object>.Ok(null);
}
}

View File

@@ -6,6 +6,7 @@ using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Serilog;
using TakeoutSaaS.Application.App.Common.Geo;
using TakeoutSaaS.Application.App.Extensions;
using TakeoutSaaS.Application.Dictionary.Extensions;
using TakeoutSaaS.Application.Identity.Extensions;
@@ -117,6 +118,9 @@ builder.Services.AddHttpClient(TencentMapGeocodingService.HttpClientName, client
client.Timeout = TimeSpan.FromSeconds(8);
});
builder.Services.AddScoped<TencentMapGeocodingService>();
builder.Services.AddScoped<IAddressGeocodingService>(provider => provider.GetRequiredService<TencentMapGeocodingService>());
builder.Services.AddScoped<GeoLocationOrchestrator>();
builder.Services.AddHostedService<GeoLocationRetryBackgroundService>();
// 10. 配置 OpenTelemetry 采集
var otelSection = builder.Configuration.GetSection("Otel");

View File

@@ -0,0 +1,340 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Application.App.Common.Geo;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.TenantApi.Services;
/// <summary>
/// 商户/门店/租户地理定位编排服务。
/// </summary>
public sealed class GeoLocationOrchestrator(
TakeoutAppDbContext dbContext,
IAddressGeocodingService geocodingService,
ITenantContextAccessor tenantContextAccessor,
ILogger<GeoLocationOrchestrator> logger)
{
private const int BatchSizePerEntity = 30;
/// <summary>
/// 手动重试门店地理编码。
/// </summary>
public async Task<bool> RetryStoreAsync(
long tenantId,
long merchantId,
long storeId,
CancellationToken cancellationToken)
{
var store = await dbContext.Stores
.FirstOrDefaultAsync(
x => x.Id == storeId && x.TenantId == tenantId && x.MerchantId == merchantId,
cancellationToken);
if (store is null)
{
logger.LogWarning("手动重试门店定位失败Store={StoreId}, Tenant={TenantId}, Merchant={MerchantId}", storeId, tenantId, merchantId);
return false;
}
var merchant = await dbContext.Merchants
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == merchantId && x.TenantId == tenantId, cancellationToken);
store.GeoRetryCount = 0;
await GeocodeStoreAsync(store, merchant, DateTime.UtcNow, isRetry: true, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogInformation("手动重试门店定位已执行Store={StoreId}", storeId);
return true;
}
/// <summary>
/// 手动重试商户地理编码。
/// </summary>
public async Task<bool> RetryMerchantAsync(
long tenantId,
long merchantId,
CancellationToken cancellationToken)
{
var merchant = await dbContext.Merchants
.FirstOrDefaultAsync(x => x.Id == merchantId && x.TenantId == tenantId, cancellationToken);
if (merchant is null)
{
logger.LogWarning("手动重试商户定位失败Merchant={MerchantId}, Tenant={TenantId}", merchantId, tenantId);
return false;
}
merchant.GeoRetryCount = 0;
await GeocodeMerchantAsync(merchant, DateTime.UtcNow, isRetry: true, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogInformation("手动重试商户定位已执行Merchant={MerchantId}", merchantId);
return true;
}
/// <summary>
/// 后台批量处理待定位记录。
/// </summary>
public async Task<int> ProcessPendingAsync(CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
var processedCount = 0;
var tenantIds = await CollectPendingTenantIdsAsync(now, cancellationToken);
foreach (var tenantId in tenantIds)
{
processedCount += await ProcessTenantEntitiesAsync(tenantId, now, cancellationToken);
}
var pendingTenants = await dbContext.Tenants
.Where(x =>
x.GeoStatus != GeoLocationStatus.Success &&
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
.OrderBy(x => x.GeoNextRetryAt)
.ThenBy(x => x.Id)
.Take(BatchSizePerEntity)
.ToListAsync(cancellationToken);
foreach (var tenant in pendingTenants)
{
await GeocodeTenantAsync(tenant, now, isRetry: true, cancellationToken);
}
processedCount += pendingTenants.Count;
if (processedCount > 0)
{
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogDebug("地理定位批处理完成,处理记录数:{Count}", processedCount);
}
return processedCount;
}
private async Task<IReadOnlyList<long>> CollectPendingTenantIdsAsync(DateTime now, CancellationToken cancellationToken)
{
var storeTenantIds = await dbContext.Stores
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x =>
x.DeletedAt == null &&
x.GeoStatus != GeoLocationStatus.Success &&
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
.Select(x => x.TenantId)
.Distinct()
.ToListAsync(cancellationToken);
var merchantTenantIds = await dbContext.Merchants
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x =>
x.DeletedAt == null &&
x.GeoStatus != GeoLocationStatus.Success &&
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
.Select(x => x.TenantId)
.Distinct()
.ToListAsync(cancellationToken);
return storeTenantIds
.Concat(merchantTenantIds)
.Distinct()
.ToList();
}
private async Task<int> ProcessTenantEntitiesAsync(
long tenantId,
DateTime now,
CancellationToken cancellationToken)
{
var previousContext = tenantContextAccessor.Current;
tenantContextAccessor.Current = new TenantContext(tenantId, $"t{tenantId}", "geo-location-retry");
try
{
var processedCount = 0;
var pendingStores = await dbContext.Stores
.Where(x =>
x.GeoStatus != GeoLocationStatus.Success &&
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
.OrderBy(x => x.GeoNextRetryAt)
.ThenBy(x => x.Id)
.Take(BatchSizePerEntity)
.ToListAsync(cancellationToken);
foreach (var store in pendingStores)
{
var merchant = await dbContext.Merchants
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == store.MerchantId, cancellationToken);
await GeocodeStoreAsync(store, merchant, now, isRetry: true, cancellationToken);
}
processedCount += pendingStores.Count;
var pendingMerchants = await dbContext.Merchants
.Where(x =>
x.GeoStatus != GeoLocationStatus.Success &&
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
.OrderBy(x => x.GeoNextRetryAt)
.ThenBy(x => x.Id)
.Take(BatchSizePerEntity)
.ToListAsync(cancellationToken);
foreach (var merchant in pendingMerchants)
{
await GeocodeMerchantAsync(merchant, now, isRetry: true, cancellationToken);
}
processedCount += pendingMerchants.Count;
if (processedCount > 0)
{
await dbContext.SaveChangesAsync(cancellationToken);
}
return processedCount;
}
finally
{
dbContext.ChangeTracker.Clear();
tenantContextAccessor.Current = previousContext;
}
}
private async Task GeocodeStoreAsync(
Store store,
Merchant? merchant,
DateTime now,
bool isRetry,
CancellationToken cancellationToken)
{
var candidates = GeoAddressBuilder.BuildStoreCandidates(store, merchant);
if (candidates.Count == 0)
{
WriteFailedState(store, "缺少地址信息,无法定位", now, isRetry);
return;
}
var geocodeResult = await ResolveCandidatesAsync(candidates, cancellationToken);
if (geocodeResult.Succeeded && geocodeResult.Latitude is not null && geocodeResult.Longitude is not null)
{
GeoLocationStateHelper.MarkSuccess(
store,
(double)geocodeResult.Latitude.Value,
(double)geocodeResult.Longitude.Value,
now);
return;
}
WriteFailedState(store, geocodeResult.Message, now, isRetry);
}
private async Task GeocodeMerchantAsync(
Merchant merchant,
DateTime now,
bool isRetry,
CancellationToken cancellationToken)
{
var candidates = GeoAddressBuilder.BuildMerchantCandidates(merchant);
if (candidates.Count == 0)
{
WriteFailedState(merchant, "缺少地址信息,无法定位", now, isRetry);
return;
}
var geocodeResult = await ResolveCandidatesAsync(candidates, cancellationToken);
if (geocodeResult.Succeeded && geocodeResult.Latitude is not null && geocodeResult.Longitude is not null)
{
GeoLocationStateHelper.MarkSuccess(
merchant,
(double)geocodeResult.Latitude.Value,
(double)geocodeResult.Longitude.Value,
now);
return;
}
WriteFailedState(merchant, geocodeResult.Message, now, isRetry);
}
private async Task GeocodeTenantAsync(
Tenant tenant,
DateTime now,
bool isRetry,
CancellationToken cancellationToken)
{
var candidates = GeoAddressBuilder.BuildTenantCandidates(tenant);
if (candidates.Count == 0)
{
WriteFailedState(tenant, "缺少地址信息,无法定位", now, isRetry);
return;
}
var geocodeResult = await ResolveCandidatesAsync(candidates, cancellationToken);
if (geocodeResult.Succeeded && geocodeResult.Latitude is not null && geocodeResult.Longitude is not null)
{
GeoLocationStateHelper.MarkSuccess(
tenant,
(double)geocodeResult.Latitude.Value,
(double)geocodeResult.Longitude.Value,
now);
return;
}
WriteFailedState(tenant, geocodeResult.Message, now, isRetry);
}
private async Task<AddressGeocodingResult> ResolveCandidatesAsync(
IReadOnlyList<string> candidates,
CancellationToken cancellationToken)
{
string? lastError = null;
foreach (var candidate in candidates)
{
var result = await geocodingService.GeocodeAsync(candidate, cancellationToken);
if (result.Succeeded)
{
return result;
}
lastError = result.Message;
}
return AddressGeocodingResult.Failed(lastError ?? "地址地理编码失败");
}
private static void WriteFailedState(Store store, string? reason, DateTime now, bool isRetry)
{
if (isRetry)
{
GeoLocationStateHelper.MarkRetryFailure(store, reason, now);
return;
}
GeoLocationStateHelper.MarkPending(store, reason, now);
}
private static void WriteFailedState(Merchant merchant, string? reason, DateTime now, bool isRetry)
{
if (isRetry)
{
GeoLocationStateHelper.MarkRetryFailure(merchant, reason, now);
return;
}
GeoLocationStateHelper.MarkPending(merchant, reason, now);
}
private static void WriteFailedState(Tenant tenant, string? reason, DateTime now, bool isRetry)
{
if (isRetry)
{
GeoLocationStateHelper.MarkRetryFailure(tenant, reason, now);
return;
}
GeoLocationStateHelper.MarkPending(tenant, reason, now);
}
}

View File

@@ -0,0 +1,46 @@
namespace TakeoutSaaS.TenantApi.Services;
/// <summary>
/// 地理定位自动重试后台服务。
/// </summary>
public sealed class GeoLocationRetryBackgroundService(
IServiceScopeFactory serviceScopeFactory,
ILogger<GeoLocationRetryBackgroundService> logger) : BackgroundService
{
private static readonly TimeSpan PollingInterval = TimeSpan.FromMinutes(1);
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = serviceScopeFactory.CreateScope();
var orchestrator = scope.ServiceProvider.GetRequiredService<GeoLocationOrchestrator>();
var processedCount = await orchestrator.ProcessPendingAsync(stoppingToken);
if (processedCount > 0)
{
logger.LogInformation("地理定位重试任务完成,处理记录数:{Count}", processedCount);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "地理定位重试任务执行失败");
}
try
{
await Task.Delay(PollingInterval, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
}

View File

@@ -3,6 +3,7 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Application.App.Common.Geo;
using TakeoutSaaS.TenantApi.Options;
namespace TakeoutSaaS.TenantApi.Services;
@@ -13,7 +14,7 @@ namespace TakeoutSaaS.TenantApi.Services;
public sealed class TencentMapGeocodingService(
IHttpClientFactory httpClientFactory,
IOptionsMonitor<TencentMapOptions> optionsMonitor,
ILogger<TencentMapGeocodingService> logger)
ILogger<TencentMapGeocodingService> logger) : IAddressGeocodingService
{
/// <summary>
/// HttpClient 名称。
@@ -29,12 +30,28 @@ public sealed class TencentMapGeocodingService(
public async Task<(decimal Latitude, decimal Longitude)?> GeocodeAsync(
string rawAddress,
CancellationToken cancellationToken)
{
var result = await GeocodeWithDetailsAsync(rawAddress, cancellationToken);
return result.Succeeded && result.Latitude is not null && result.Longitude is not null
? (result.Latitude.Value, result.Longitude.Value)
: null;
}
/// <summary>
/// 根据地址解析经纬度(含失败原因)。
/// </summary>
/// <param name="rawAddress">地址文本。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>解析结果。</returns>
public async Task<AddressGeocodingResult> GeocodeWithDetailsAsync(
string rawAddress,
CancellationToken cancellationToken)
{
// 1. 预处理地址文本,空值直接返回。
var address = rawAddress?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(address))
{
return null;
return AddressGeocodingResult.Failed("地址为空,无法定位");
}
// 2. 读取腾讯地图配置并做兜底校验。
@@ -44,12 +61,13 @@ public sealed class TencentMapGeocodingService(
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(secret))
{
logger.LogWarning("腾讯地图 WebService 未配置 key/sk已跳过地址解析。");
return null;
return AddressGeocodingResult.Failed("地图服务未配置");
}
// 3. 同时尝试两种签名方式,兼容不同编码策略。
var baseUrl = NormalizeBaseUrl(options.BaseUrl);
var geocoderPath = NormalizePath(options.GeocoderPath);
string? lastMessage = null;
foreach (var useEncodedValueInSignature in new[] { false, true })
{
var query = new SortedDictionary<string, string>(StringComparer.Ordinal)
@@ -67,6 +85,7 @@ public sealed class TencentMapGeocodingService(
var response = await RequestAsync(requestUri, cancellationToken);
if (response is null)
{
lastMessage = "地图服务响应无法解析";
continue;
}
@@ -75,9 +94,13 @@ public sealed class TencentMapGeocodingService(
response.Latitude is not null &&
response.Longitude is not null)
{
return (response.Latitude.Value, response.Longitude.Value);
return AddressGeocodingResult.Success(response.Latitude.Value, response.Longitude.Value);
}
lastMessage = string.IsNullOrWhiteSpace(response.Message)
? $"地理编码失败,状态码:{response.Status}"
: response.Message;
// 5. 仅在签名错误时继续下一轮重试,其他状态直接终止。
if (response.Status != 111)
{
@@ -85,9 +108,14 @@ public sealed class TencentMapGeocodingService(
}
}
return null;
return AddressGeocodingResult.Failed(lastMessage ?? "地理编码失败");
}
Task<AddressGeocodingResult> IAddressGeocodingService.GeocodeAsync(
string address,
CancellationToken cancellationToken)
=> GeocodeWithDetailsAsync(address, cancellationToken);
private async Task<TencentGeocodeResponse?> RequestAsync(
string requestUri,
CancellationToken cancellationToken)

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

View File

@@ -46,6 +46,30 @@ public sealed class CreateMerchantCommand : IRequest<MerchantDto>
[MaxLength(128)]
public string? ContactEmail { get; init; }
/// <summary>
/// 所在省份。
/// </summary>
[MaxLength(64)]
public string? Province { get; init; }
/// <summary>
/// 所在城市。
/// </summary>
[MaxLength(64)]
public string? City { get; init; }
/// <summary>
/// 所在区县。
/// </summary>
[MaxLength(64)]
public string? District { get; init; }
/// <summary>
/// 详细地址。
/// </summary>
[MaxLength(256)]
public string? Address { get; init; }
/// <summary>
/// 状态,可用于直接设为审核通过等场景。
/// </summary>

View File

@@ -77,6 +77,21 @@ public sealed class MerchantDetailDto
/// </summary>
public double? Latitude { get; init; }
/// <summary>
/// 地理定位状态。
/// </summary>
public GeoLocationStatus GeoStatus { get; init; }
/// <summary>
/// 地理定位失败原因。
/// </summary>
public string? GeoFailReason { get; init; }
/// <summary>
/// 地理定位最近成功时间。
/// </summary>
public DateTime? GeoUpdatedAt { get; init; }
/// <summary>
/// 联系电话。
/// </summary>

View File

@@ -1,6 +1,7 @@
using MediatR;
using Microsoft.Extensions.Logging;
using System.Security.Cryptography;
using TakeoutSaaS.Application.App.Common.Geo;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
@@ -11,7 +12,10 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 创建商户命令处理器。
/// </summary>
public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRepository, ILogger<CreateMerchantCommandHandler> logger)
public sealed class CreateMerchantCommandHandler(
IMerchantRepository merchantRepository,
IAddressGeocodingService geocodingService,
ILogger<CreateMerchantCommandHandler> logger)
: IRequestHandler<CreateMerchantCommand, MerchantDto>
{
/// <inheritdoc />
@@ -26,10 +30,15 @@ public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRep
Category = request.Category?.Trim(),
ContactPhone = request.ContactPhone.Trim(),
ContactEmail = request.ContactEmail?.Trim(),
Province = request.Province?.Trim(),
City = request.City?.Trim(),
District = request.District?.Trim(),
Address = request.Address?.Trim(),
Status = request.Status,
RowVersion = RandomNumberGenerator.GetBytes(16),
JoinedAt = DateTime.UtcNow
};
await TryGeocodeMerchantAsync(merchant, cancellationToken);
// 2. 持久化
await merchantRepository.AddMerchantAsync(merchant, cancellationToken);
@@ -40,6 +49,36 @@ public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRep
return MapToDto(merchant);
}
private async Task TryGeocodeMerchantAsync(Merchant merchant, CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
var candidates = GeoAddressBuilder.BuildMerchantCandidates(merchant);
if (candidates.Count == 0)
{
GeoLocationStateHelper.MarkPending(merchant, "缺少地址信息,等待补全后自动定位", now);
return;
}
string? lastError = null;
foreach (var candidate in candidates)
{
var result = await geocodingService.GeocodeAsync(candidate, cancellationToken);
if (result.Succeeded && result.Latitude is not null && result.Longitude is not null)
{
GeoLocationStateHelper.MarkSuccess(
merchant,
(double)result.Latitude.Value,
(double)result.Longitude.Value,
now);
return;
}
lastError = result.Message;
}
GeoLocationStateHelper.MarkPending(merchant, lastError ?? "地址地理编码失败,等待自动重试", now);
}
private static MerchantDto MapToDto(Merchant merchant) => new()
{
Id = merchant.Id,

View File

@@ -1,5 +1,6 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Common.Geo;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
@@ -23,6 +24,7 @@ public sealed class UpdateMerchantCommandHandler(
ITenantRepository tenantRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAddressGeocodingService geocodingService,
ILogger<UpdateMerchantCommandHandler> logger)
: IRequestHandler<UpdateMerchantCommand, UpdateMerchantResultDto?>
{
@@ -47,6 +49,9 @@ public sealed class UpdateMerchantCommandHandler(
var legalRepresentative = NormalizeOptional(request.LegalRepresentative);
var registeredAddress = NormalizeOptional(request.RegisteredAddress);
var contactEmail = NormalizeOptional(request.ContactEmail);
var shouldReGeocode = !merchant.Latitude.HasValue ||
!merchant.Longitude.HasValue ||
!string.Equals(merchant.Address, registeredAddress, StringComparison.Ordinal);
var now = DateTime.UtcNow;
var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
@@ -68,6 +73,10 @@ public sealed class UpdateMerchantCommandHandler(
merchant.Address = registeredAddress;
merchant.ContactPhone = contactPhone;
merchant.ContactEmail = contactEmail;
if (shouldReGeocode)
{
await TryGeocodeMerchantAsync(merchant, now, cancellationToken);
}
var requiresReview = merchant.Status == MerchantStatus.Approved && criticalChanged;
if (requiresReview)
@@ -156,6 +165,35 @@ public sealed class UpdateMerchantCommandHandler(
}
}
private async Task TryGeocodeMerchantAsync(Merchant merchant, DateTime now, CancellationToken cancellationToken)
{
var candidates = GeoAddressBuilder.BuildMerchantCandidates(merchant);
if (candidates.Count == 0)
{
GeoLocationStateHelper.MarkPending(merchant, "缺少地址信息,等待补全后自动定位", now);
return;
}
string? lastError = null;
foreach (var candidate in candidates)
{
var result = await geocodingService.GeocodeAsync(candidate, cancellationToken);
if (result.Succeeded && result.Latitude is not null && result.Longitude is not null)
{
GeoLocationStateHelper.MarkSuccess(
merchant,
(double)result.Latitude.Value,
(double)result.Longitude.Value,
now);
return;
}
lastError = result.Message;
}
GeoLocationStateHelper.MarkPending(merchant, lastError ?? "地址地理编码失败,等待自动重试", now);
}
private static string NormalizeRequired(string? value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))

View File

@@ -65,6 +65,9 @@ internal static class MerchantMapping
District = merchant.District,
Longitude = merchant.Longitude,
Latitude = merchant.Latitude,
GeoStatus = merchant.GeoStatus,
GeoFailReason = merchant.GeoFailReason,
GeoUpdatedAt = merchant.GeoUpdatedAt,
ContactPhone = merchant.ContactPhone,
ContactEmail = merchant.ContactEmail,
Status = merchant.Status,

View File

@@ -19,5 +19,9 @@ public sealed class CreateMerchantCommandValidator : AbstractValidator<CreateMer
RuleFor(x => x.Category).MaximumLength(64);
RuleFor(x => x.ContactPhone).NotEmpty().MaximumLength(32);
RuleFor(x => x.ContactEmail).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ContactEmail));
RuleFor(x => x.Province).MaximumLength(64);
RuleFor(x => x.City).MaximumLength(64);
RuleFor(x => x.District).MaximumLength(64);
RuleFor(x => x.Address).MaximumLength(256);
}
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
@@ -143,6 +144,21 @@ public sealed class StoreDto
/// </summary>
public double? Latitude { get; init; }
/// <summary>
/// 地理定位状态。
/// </summary>
public GeoLocationStatus GeoStatus { get; init; }
/// <summary>
/// 地理定位失败原因。
/// </summary>
public string? GeoFailReason { get; init; }
/// <summary>
/// 地理定位最近成功时间。
/// </summary>
public DateTime? GeoUpdatedAt { get; init; }
/// <summary>
/// 公告。
/// </summary>

View File

@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Application.App.Stores.Enums;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
@@ -66,6 +67,21 @@ public sealed record StoreListItemDto
/// </summary>
public double? Latitude { get; init; }
/// <summary>
/// 地理定位状态。
/// </summary>
public GeoLocationStatus GeoStatus { get; init; }
/// <summary>
/// 地理定位失败原因。
/// </summary>
public string? GeoFailReason { get; init; }
/// <summary>
/// 地理定位最近成功时间。
/// </summary>
public DateTime? GeoUpdatedAt { get; init; }
/// <summary>
/// 门店封面图。
/// </summary>

View File

@@ -1,8 +1,10 @@
using MediatR;
using System.Security.Cryptography;
using TakeoutSaaS.Application.App.Common.Geo;
using TakeoutSaaS.Application.App.Stores.Commands;
using TakeoutSaaS.Application.App.Stores.Enums;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Domain.Stores.Repositories;
@@ -18,6 +20,8 @@ namespace TakeoutSaaS.Application.App.Stores.Handlers;
public sealed class CreateStoreCommandHandler(
StoreContextService storeContextService,
IStoreRepository storeRepository,
IMerchantRepository merchantRepository,
IAddressGeocodingService geocodingService,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<CreateStoreCommand>
{
@@ -64,6 +68,7 @@ public sealed class CreateStoreCommandHandler(
Status = StoreStatus.Operating
};
StoreListMapping.ApplyServiceTypes(store, serviceTypes);
await TryGeocodeStoreAsync(store, context.TenantId, context.MerchantId, now, cancellationToken);
// 4. 持久化门店并记录自动审核通过
await storeRepository.AddStoreAsync(store, cancellationToken);
@@ -82,6 +87,41 @@ public sealed class CreateStoreCommandHandler(
await storeRepository.SaveChangesAsync(cancellationToken);
}
private async Task TryGeocodeStoreAsync(
Store store,
long tenantId,
long merchantId,
DateTime now,
CancellationToken cancellationToken)
{
var merchant = await merchantRepository.FindByIdAsync(merchantId, tenantId, cancellationToken);
var candidates = GeoAddressBuilder.BuildStoreCandidates(store, merchant);
if (candidates.Count == 0)
{
GeoLocationStateHelper.MarkPending(store, "缺少地址信息,等待补全后自动定位", now);
return;
}
string? lastError = null;
foreach (var candidate in candidates)
{
var result = await geocodingService.GeocodeAsync(candidate, cancellationToken);
if (result.Succeeded && result.Latitude is not null && result.Longitude is not null)
{
GeoLocationStateHelper.MarkSuccess(
store,
(double)result.Latitude.Value,
(double)result.Longitude.Value,
now);
return;
}
lastError = result.Message;
}
GeoLocationStateHelper.MarkPending(store, lastError ?? "地址地理编码失败,等待自动重试", now);
}
/// <summary>
/// 生成当前租户商户内唯一的门店编码。
/// </summary>

View File

@@ -1,7 +1,10 @@
using MediatR;
using TakeoutSaaS.Application.App.Common.Geo;
using TakeoutSaaS.Application.App.Stores.Commands;
using TakeoutSaaS.Application.App.Stores.Enums;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
@@ -13,7 +16,9 @@ namespace TakeoutSaaS.Application.App.Stores.Handlers;
/// </summary>
public sealed class UpdateStoreCommandHandler(
StoreContextService storeContextService,
IStoreRepository storeRepository)
IStoreRepository storeRepository,
IMerchantRepository merchantRepository,
IAddressGeocodingService geocodingService)
: IRequestHandler<UpdateStoreCommand>
{
/// <inheritdoc />
@@ -29,6 +34,11 @@ public sealed class UpdateStoreCommandHandler(
{
throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他商户门店");
}
var now = DateTime.UtcNow;
var normalizedAddress = request.Address.Trim();
var shouldReGeocode = !existing.Latitude.HasValue ||
!existing.Longitude.HasValue ||
!string.Equals(existing.Address?.Trim(), normalizedAddress, StringComparison.Ordinal);
// 3. 若传入编码且发生变化,则校验唯一性并更新编码。
var normalizedCode = request.Code?.Trim();
@@ -60,7 +70,7 @@ public sealed class UpdateStoreCommandHandler(
existing.Name = request.Name.Trim();
existing.Phone = request.ContactPhone.Trim();
existing.ManagerName = request.ManagerName.Trim();
existing.Address = request.Address.Trim();
existing.Address = normalizedAddress;
existing.CoverImageUrl = request.CoverImage?.Trim();
existing.SignboardImageUrl = request.CoverImage?.Trim();
existing.BusinessStatus = request.BusinessStatus ?? existing.BusinessStatus;
@@ -68,9 +78,48 @@ public sealed class UpdateStoreCommandHandler(
? request.ServiceTypes
: [ServiceType.Delivery];
StoreListMapping.ApplyServiceTypes(existing, serviceTypes);
if (shouldReGeocode)
{
await TryGeocodeStoreAsync(existing, context.TenantId, context.MerchantId, now, cancellationToken);
}
// 5. 保存修改
await storeRepository.UpdateStoreAsync(existing, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken);
}
private async Task TryGeocodeStoreAsync(
Store store,
long tenantId,
long merchantId,
DateTime now,
CancellationToken cancellationToken)
{
var merchant = await merchantRepository.FindByIdAsync(merchantId, tenantId, cancellationToken);
var candidates = GeoAddressBuilder.BuildStoreCandidates(store, merchant);
if (candidates.Count == 0)
{
GeoLocationStateHelper.MarkPending(store, "缺少地址信息,等待补全后自动定位", now);
return;
}
string? lastError = null;
foreach (var candidate in candidates)
{
var result = await geocodingService.GeocodeAsync(candidate, cancellationToken);
if (result.Succeeded && result.Latitude is not null && result.Longitude is not null)
{
GeoLocationStateHelper.MarkSuccess(
store,
(double)result.Latitude.Value,
(double)result.Longitude.Value,
now);
return;
}
lastError = result.Message;
}
GeoLocationStateHelper.MarkPending(store, lastError ?? "地址地理编码失败,等待自动重试", now);
}
}

View File

@@ -27,6 +27,9 @@ public static class StoreListMapping
District = store.District,
Longitude = store.Longitude,
Latitude = store.Latitude,
GeoStatus = store.GeoStatus,
GeoFailReason = store.GeoFailReason,
GeoUpdatedAt = store.GeoUpdatedAt,
CoverImage = string.IsNullOrWhiteSpace(store.CoverImageUrl) ? store.SignboardImageUrl : store.CoverImageUrl,
BusinessStatus = store.BusinessStatus,
AuditStatus = store.AuditStatus,

View File

@@ -42,6 +42,9 @@ public static class StoreMapping
Address = store.Address,
Longitude = store.Longitude,
Latitude = store.Latitude,
GeoStatus = store.GeoStatus,
GeoFailReason = store.GeoFailReason,
GeoUpdatedAt = store.GeoUpdatedAt,
Announcement = store.Announcement,
Tags = store.Tags,
DeliveryRadiusKm = store.DeliveryRadiusKm,

View File

@@ -37,6 +37,30 @@ public sealed record SelfRegisterTenantCommand : IRequest<SelfRegisterResultDto>
[StringLength(32)]
public string AdminPhone { get; init; } = string.Empty;
/// <summary>
/// 国家/地区。
/// </summary>
[StringLength(64)]
public string? Country { get; init; }
/// <summary>
/// 省份。
/// </summary>
[StringLength(64)]
public string? Province { get; init; }
/// <summary>
/// 城市。
/// </summary>
[StringLength(64)]
public string? City { get; init; }
/// <summary>
/// 详细地址。
/// </summary>
[StringLength(256)]
public string? Address { get; init; }
/// <summary>
/// 初始管理员登录密码(前端自定义)。
/// </summary>

View File

@@ -1,5 +1,6 @@
using MediatR;
using Microsoft.AspNetCore.Identity;
using TakeoutSaaS.Application.App.Common.Geo;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.Identity.Commands;
@@ -25,7 +26,8 @@ public sealed class SelfRegisterTenantCommandHandler(
IPasswordHasher<IdentityUser> passwordHasher,
IIdGenerator idGenerator,
IMediator mediator,
ITenantContextAccessor tenantContextAccessor)
ITenantContextAccessor tenantContextAccessor,
IAddressGeocodingService geocodingService)
: IRequestHandler<SelfRegisterTenantCommand, SelfRegisterResultDto>
{
/// <inheritdoc />
@@ -59,10 +61,15 @@ public sealed class SelfRegisterTenantCommandHandler(
ContactName = string.IsNullOrWhiteSpace(request.AdminDisplayName) ? normalizedAccount : request.AdminDisplayName!.Trim(),
ContactPhone = normalizedPhone,
ContactEmail = request.AdminEmail,
Country = NormalizeOptional(request.Country),
Province = NormalizeOptional(request.Province),
City = NormalizeOptional(request.City),
Address = NormalizeOptional(request.Address),
Status = TenantStatus.PendingReview,
EffectiveFrom = null,
EffectiveTo = null
};
await TryGeocodeTenantAsync(tenant, geocodingService, cancellationToken);
// 4. 写入审计日志
var auditLog = new TenantAuditLog
@@ -137,4 +144,40 @@ public sealed class SelfRegisterTenantCommandHandler(
tenantContextAccessor.Current = previousContext;
}
}
private static async Task TryGeocodeTenantAsync(
Tenant tenant,
IAddressGeocodingService geocodingService,
CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
var candidates = GeoAddressBuilder.BuildTenantCandidates(tenant);
if (candidates.Count == 0)
{
GeoLocationStateHelper.MarkPending(tenant, "缺少地址信息,等待补全后自动定位", now);
return;
}
string? lastError = null;
foreach (var candidate in candidates)
{
var result = await geocodingService.GeocodeAsync(candidate, cancellationToken);
if (result.Succeeded && result.Latitude is not null && result.Longitude is not null)
{
GeoLocationStateHelper.MarkSuccess(
tenant,
(double)result.Latitude.Value,
(double)result.Longitude.Value,
now);
return;
}
lastError = result.Message;
}
GeoLocationStateHelper.MarkPending(tenant, lastError ?? "地址地理编码失败,等待自动重试", now);
}
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -18,5 +18,9 @@ public sealed class SelfRegisterTenantCommandValidator : AbstractValidator<SelfR
.MaximumLength(64)
.Matches("^[A-Za-z0-9]+$")
.WithMessage("登录账号仅允许大小写字母和数字");
RuleFor(x => x.Country).MaximumLength(64);
RuleFor(x => x.Province).MaximumLength(64);
RuleFor(x => x.City).MaximumLength(64);
RuleFor(x => x.Address).MaximumLength(256);
}
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Common.Enums;
/// <summary>
/// 地理定位状态。
/// </summary>
public enum GeoLocationStatus
{
/// <summary>
/// 待定位(可重试)。
/// </summary>
Pending = 0,
/// <summary>
/// 定位成功。
/// </summary>
Success = 1,
/// <summary>
/// 定位失败(达到重试上限或不可恢复错误)。
/// </summary>
Failed = 2
}

View File

@@ -99,6 +99,31 @@ public sealed class Merchant : MultiTenantEntityBase
/// </summary>
public double? Latitude { get; set; }
/// <summary>
/// 地理定位状态。
/// </summary>
public GeoLocationStatus GeoStatus { get; set; } = GeoLocationStatus.Pending;
/// <summary>
/// 地理定位失败原因。
/// </summary>
public string? GeoFailReason { get; set; }
/// <summary>
/// 地理定位重试次数。
/// </summary>
public int GeoRetryCount { get; set; }
/// <summary>
/// 地理定位最近成功时间UTC
/// </summary>
public DateTime? GeoUpdatedAt { get; set; }
/// <summary>
/// 下次地理定位重试时间UTC
/// </summary>
public DateTime? GeoNextRetryAt { get; set; }
/// <summary>
/// 入驻状态。
/// </summary>

View File

@@ -1,3 +1,4 @@
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
@@ -153,6 +154,31 @@ public sealed class Store : MultiTenantEntityBase
/// </summary>
public double? Latitude { get; set; }
/// <summary>
/// 地理定位状态。
/// </summary>
public GeoLocationStatus GeoStatus { get; set; } = GeoLocationStatus.Pending;
/// <summary>
/// 地理定位失败原因。
/// </summary>
public string? GeoFailReason { get; set; }
/// <summary>
/// 地理定位重试次数。
/// </summary>
public int GeoRetryCount { get; set; }
/// <summary>
/// 地理定位最近成功时间UTC
/// </summary>
public DateTime? GeoUpdatedAt { get; set; }
/// <summary>
/// 下次地理定位重试时间UTC
/// </summary>
public DateTime? GeoNextRetryAt { get; set; }
/// <summary>
/// 门店描述或公告。
/// </summary>

View File

@@ -69,6 +69,41 @@ public sealed class Tenant : AuditableEntityBase
/// </summary>
public string? Address { get; set; }
/// <summary>
/// 经度信息。
/// </summary>
public double? Longitude { get; set; }
/// <summary>
/// 纬度信息。
/// </summary>
public double? Latitude { get; set; }
/// <summary>
/// 地理定位状态。
/// </summary>
public GeoLocationStatus GeoStatus { get; set; } = GeoLocationStatus.Pending;
/// <summary>
/// 地理定位失败原因。
/// </summary>
public string? GeoFailReason { get; set; }
/// <summary>
/// 地理定位重试次数。
/// </summary>
public int GeoRetryCount { get; set; }
/// <summary>
/// 地理定位最近成功时间UTC
/// </summary>
public DateTime? GeoUpdatedAt { get; set; }
/// <summary>
/// 下次地理定位重试时间UTC
/// </summary>
public DateTime? GeoNextRetryAt { get; set; }
/// <summary>
/// 主联系人姓名。
/// </summary>

View File

@@ -5,6 +5,7 @@ using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.CustomerService.Entities;
using TakeoutSaaS.Domain.Deliveries.Entities;
using TakeoutSaaS.Domain.Distribution.Entities;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Engagement.Entities;
using TakeoutSaaS.Domain.GroupBuying.Entities;
using TakeoutSaaS.Domain.Inventory.Entities;
@@ -505,10 +506,16 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.ContactEmail).HasMaxLength(128);
builder.Property(x => x.Industry).HasMaxLength(64);
builder.Property(x => x.LogoUrl).HasColumnType("text");
builder.Property(x => x.GeoStatus).HasConversion<int>().HasDefaultValue(GeoLocationStatus.Pending);
builder.Property(x => x.GeoFailReason).HasMaxLength(500);
builder.Property(x => x.GeoRetryCount).HasDefaultValue(0);
builder.Property(x => x.Remarks).HasMaxLength(512);
builder.Property(x => x.OperatingMode).HasConversion<int>();
builder.HasIndex(x => x.Code).IsUnique();
builder.HasIndex(x => x.ContactPhone).IsUnique();
builder.HasIndex(x => new { x.GeoStatus, x.GeoNextRetryAt });
builder.HasIndex(x => new { x.Longitude, x.Latitude })
.HasFilter("\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL");
}
private static void ConfigureTenantVerificationProfile(EntityTypeBuilder<TenantVerificationProfile> builder)
@@ -558,6 +565,9 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.Address).HasMaxLength(256);
builder.Property(x => x.ReviewRemarks).HasMaxLength(512);
builder.Property(x => x.OperatingMode).HasConversion<int>();
builder.Property(x => x.GeoStatus).HasConversion<int>().HasDefaultValue(GeoLocationStatus.Pending);
builder.Property(x => x.GeoFailReason).HasMaxLength(500);
builder.Property(x => x.GeoRetryCount).HasDefaultValue(0);
builder.Property(x => x.IsFrozen).HasDefaultValue(false);
builder.Property(x => x.FrozenReason).HasMaxLength(500);
builder.Property(x => x.ClaimedByName).HasMaxLength(100);
@@ -570,6 +580,9 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.Status });
builder.HasIndex(x => x.ClaimedBy);
builder.HasIndex(x => new { x.TenantId, x.GeoStatus, x.GeoNextRetryAt });
builder.HasIndex(x => new { x.Longitude, x.Latitude })
.HasFilter("\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL");
}
private static void ConfigureStore(EntityTypeBuilder<Store> builder)
@@ -599,11 +612,15 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.BusinessHours).HasMaxLength(256);
builder.Property(x => x.Announcement).HasMaxLength(512);
builder.Property(x => x.DeliveryRadiusKm).HasPrecision(6, 2);
builder.Property(x => x.GeoStatus).HasConversion<int>().HasDefaultValue(GeoLocationStatus.Pending);
builder.Property(x => x.GeoFailReason).HasMaxLength(500);
builder.Property(x => x.GeoRetryCount).HasDefaultValue(0);
builder.HasIndex(x => new { x.TenantId, x.MerchantId });
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.AuditStatus });
builder.HasIndex(x => new { x.TenantId, x.BusinessStatus });
builder.HasIndex(x => new { x.TenantId, x.OwnershipType });
builder.HasIndex(x => new { x.TenantId, x.MerchantId, x.GeoStatus, x.GeoNextRetryAt });
builder.HasIndex(x => new { x.Longitude, x.Latitude })
.HasFilter("\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL");
builder.HasIndex(x => new { x.MerchantId, x.BusinessLicenseNumber })

View File

@@ -0,0 +1,262 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGeoLocationRetryMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "GeoFailReason",
table: "tenants",
type: "character varying(500)",
maxLength: 500,
nullable: true,
comment: "地理定位失败原因。");
migrationBuilder.AddColumn<DateTime>(
name: "GeoNextRetryAt",
table: "tenants",
type: "timestamp with time zone",
nullable: true,
comment: "下次地理定位重试时间UTC。");
migrationBuilder.AddColumn<int>(
name: "GeoRetryCount",
table: "tenants",
type: "integer",
nullable: false,
defaultValue: 0,
comment: "地理定位重试次数。");
migrationBuilder.AddColumn<int>(
name: "GeoStatus",
table: "tenants",
type: "integer",
nullable: false,
defaultValue: 0,
comment: "地理定位状态。");
migrationBuilder.AddColumn<DateTime>(
name: "GeoUpdatedAt",
table: "tenants",
type: "timestamp with time zone",
nullable: true,
comment: "地理定位最近成功时间UTC。");
migrationBuilder.AddColumn<double>(
name: "Latitude",
table: "tenants",
type: "double precision",
nullable: true,
comment: "纬度信息。");
migrationBuilder.AddColumn<double>(
name: "Longitude",
table: "tenants",
type: "double precision",
nullable: true,
comment: "经度信息。");
migrationBuilder.AddColumn<string>(
name: "GeoFailReason",
table: "stores",
type: "character varying(500)",
maxLength: 500,
nullable: true,
comment: "地理定位失败原因。");
migrationBuilder.AddColumn<DateTime>(
name: "GeoNextRetryAt",
table: "stores",
type: "timestamp with time zone",
nullable: true,
comment: "下次地理定位重试时间UTC。");
migrationBuilder.AddColumn<int>(
name: "GeoRetryCount",
table: "stores",
type: "integer",
nullable: false,
defaultValue: 0,
comment: "地理定位重试次数。");
migrationBuilder.AddColumn<int>(
name: "GeoStatus",
table: "stores",
type: "integer",
nullable: false,
defaultValue: 0,
comment: "地理定位状态。");
migrationBuilder.AddColumn<DateTime>(
name: "GeoUpdatedAt",
table: "stores",
type: "timestamp with time zone",
nullable: true,
comment: "地理定位最近成功时间UTC。");
migrationBuilder.AddColumn<string>(
name: "GeoFailReason",
table: "merchants",
type: "character varying(500)",
maxLength: 500,
nullable: true,
comment: "地理定位失败原因。");
migrationBuilder.AddColumn<DateTime>(
name: "GeoNextRetryAt",
table: "merchants",
type: "timestamp with time zone",
nullable: true,
comment: "下次地理定位重试时间UTC。");
migrationBuilder.AddColumn<int>(
name: "GeoRetryCount",
table: "merchants",
type: "integer",
nullable: false,
defaultValue: 0,
comment: "地理定位重试次数。");
migrationBuilder.AddColumn<int>(
name: "GeoStatus",
table: "merchants",
type: "integer",
nullable: false,
defaultValue: 0,
comment: "地理定位状态。");
migrationBuilder.AddColumn<DateTime>(
name: "GeoUpdatedAt",
table: "merchants",
type: "timestamp with time zone",
nullable: true,
comment: "地理定位最近成功时间UTC。");
migrationBuilder.CreateIndex(
name: "IX_tenants_GeoStatus_GeoNextRetryAt",
table: "tenants",
columns: new[] { "GeoStatus", "GeoNextRetryAt" });
migrationBuilder.CreateIndex(
name: "IX_tenants_Longitude_Latitude",
table: "tenants",
columns: new[] { "Longitude", "Latitude" },
filter: "\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_stores_TenantId_MerchantId_GeoStatus_GeoNextRetryAt",
table: "stores",
columns: new[] { "TenantId", "MerchantId", "GeoStatus", "GeoNextRetryAt" });
migrationBuilder.CreateIndex(
name: "IX_merchants_Longitude_Latitude",
table: "merchants",
columns: new[] { "Longitude", "Latitude" },
filter: "\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_merchants_TenantId_GeoStatus_GeoNextRetryAt",
table: "merchants",
columns: new[] { "TenantId", "GeoStatus", "GeoNextRetryAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_tenants_GeoStatus_GeoNextRetryAt",
table: "tenants");
migrationBuilder.DropIndex(
name: "IX_tenants_Longitude_Latitude",
table: "tenants");
migrationBuilder.DropIndex(
name: "IX_stores_TenantId_MerchantId_GeoStatus_GeoNextRetryAt",
table: "stores");
migrationBuilder.DropIndex(
name: "IX_merchants_Longitude_Latitude",
table: "merchants");
migrationBuilder.DropIndex(
name: "IX_merchants_TenantId_GeoStatus_GeoNextRetryAt",
table: "merchants");
migrationBuilder.DropColumn(
name: "GeoFailReason",
table: "tenants");
migrationBuilder.DropColumn(
name: "GeoNextRetryAt",
table: "tenants");
migrationBuilder.DropColumn(
name: "GeoRetryCount",
table: "tenants");
migrationBuilder.DropColumn(
name: "GeoStatus",
table: "tenants");
migrationBuilder.DropColumn(
name: "GeoUpdatedAt",
table: "tenants");
migrationBuilder.DropColumn(
name: "Latitude",
table: "tenants");
migrationBuilder.DropColumn(
name: "Longitude",
table: "tenants");
migrationBuilder.DropColumn(
name: "GeoFailReason",
table: "stores");
migrationBuilder.DropColumn(
name: "GeoNextRetryAt",
table: "stores");
migrationBuilder.DropColumn(
name: "GeoRetryCount",
table: "stores");
migrationBuilder.DropColumn(
name: "GeoStatus",
table: "stores");
migrationBuilder.DropColumn(
name: "GeoUpdatedAt",
table: "stores");
migrationBuilder.DropColumn(
name: "GeoFailReason",
table: "merchants");
migrationBuilder.DropColumn(
name: "GeoNextRetryAt",
table: "merchants");
migrationBuilder.DropColumn(
name: "GeoRetryCount",
table: "merchants");
migrationBuilder.DropColumn(
name: "GeoStatus",
table: "merchants");
migrationBuilder.DropColumn(
name: "GeoUpdatedAt",
table: "merchants");
}
}
}

View File

@@ -2468,6 +2468,31 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(500)")
.HasComment("冻结原因。");
b.Property<string>("GeoFailReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasComment("地理定位失败原因。");
b.Property<DateTime?>("GeoNextRetryAt")
.HasColumnType("timestamp with time zone")
.HasComment("下次地理定位重试时间UTC。");
b.Property<int>("GeoRetryCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasComment("地理定位重试次数。");
b.Property<int>("GeoStatus")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasComment("地理定位状态。");
b.Property<DateTime?>("GeoUpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("地理定位最近成功时间UTC。");
b.Property<bool>("IsFrozen")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
@@ -2557,8 +2582,13 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.HasIndex("TenantId");
b.HasIndex("Longitude", "Latitude")
.HasFilter("\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL");
b.HasIndex("TenantId", "Status");
b.HasIndex("TenantId", "GeoStatus", "GeoNextRetryAt");
b.ToTable("merchants", null, t =>
{
t.HasComment("商户主体信息,承载入驻和资质审核结果。");
@@ -4935,6 +4965,31 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("timestamp with time zone")
.HasComment("强制关闭时间。");
b.Property<string>("GeoFailReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasComment("地理定位失败原因。");
b.Property<DateTime?>("GeoNextRetryAt")
.HasColumnType("timestamp with time zone")
.HasComment("下次地理定位重试时间UTC。");
b.Property<int>("GeoRetryCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasComment("地理定位重试次数。");
b.Property<int>("GeoStatus")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasComment("地理定位状态。");
b.Property<DateTime?>("GeoUpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("地理定位最近成功时间UTC。");
b.Property<double?>("Latitude")
.HasColumnType("double precision")
.HasComment("纬度。");
@@ -5056,6 +5111,8 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.HasIndex("TenantId", "OwnershipType");
b.HasIndex("TenantId", "MerchantId", "GeoStatus", "GeoNextRetryAt");
b.ToTable("stores", null, t =>
{
t.HasComment("门店信息,承载营业配置与能力。");
@@ -6460,11 +6517,40 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("timestamp with time zone")
.HasComment("服务到期时间UTC。");
b.Property<string>("GeoFailReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasComment("地理定位失败原因。");
b.Property<DateTime?>("GeoNextRetryAt")
.HasColumnType("timestamp with time zone")
.HasComment("下次地理定位重试时间UTC。");
b.Property<int>("GeoRetryCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasComment("地理定位重试次数。");
b.Property<int>("GeoStatus")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasComment("地理定位状态。");
b.Property<DateTime?>("GeoUpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("地理定位最近成功时间UTC。");
b.Property<string>("Industry")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("所属行业,如餐饮、零售等。");
b.Property<double?>("Latitude")
.HasColumnType("double precision")
.HasComment("纬度信息。");
b.Property<string>("LegalEntityName")
.HasColumnType("text")
.HasComment("法人或公司主体名称。");
@@ -6473,6 +6559,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("text")
.HasComment("LOGO 图片地址。");
b.Property<double?>("Longitude")
.HasColumnType("double precision")
.HasComment("经度信息。");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
@@ -6537,6 +6627,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.HasIndex("ContactPhone")
.IsUnique();
b.HasIndex("GeoStatus", "GeoNextRetryAt");
b.HasIndex("Longitude", "Latitude")
.HasFilter("\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL");
b.ToTable("tenants", null, t =>
{
t.HasComment("租户信息,描述租户的生命周期与基础资料。");